Skip to content
Merged
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ JWT_DEFAULT_DURATION=3600 # 1 hour
JWT_REFRESH_DURATION=2592000 # 30 days

# Comma-separated exact origins allowed for credentialed CORS.
# Include http://localhost:3001 (and 127.0.0.1) when @studiqo/web runs on 3001 with API on PORT=3000.
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:5173,http://127.0.0.1:5173
# Include localhost:3001 / :3002 when @studiqo/web runs there with API on PORT=3000.
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:3002,http://127.0.0.1:3002,http://localhost:5173,http://127.0.0.1:5173
# Comma-separated hostname suffixes allowed for credentialed CORS.
CORS_ALLOWED_ORIGIN_SUFFIXES=.studiqo.io

Expand All @@ -23,7 +23,7 @@ APP_BASE_DOMAIN=studiqo.io

# Invitation settings.
INVITATION_EXPIRES_IN_HOURS=168
INVITATION_ACCEPT_PATH=/invite
INVITATION_ACCEPT_PATH=/invites

# Resend email settings.
RESEND_API_KEY=
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ export const config: Config = {
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
"http://localhost:3002",
"http://127.0.0.1:3002",
"http://localhost:5173",
"http://127.0.0.1:5173",
]),
Expand Down Expand Up @@ -232,7 +234,7 @@ export const config: Config = {
},
invitations: {
expiresInHours: numberEnvOrDefault("INVITATION_EXPIRES_IN_HOURS", 168),
acceptPath: envOrDefault("INVITATION_ACCEPT_PATH", "/invite"),
acceptPath: envOrDefault("INVITATION_ACCEPT_PATH", "/invites"),
},
resend: {
apiKey: process.env.RESEND_API_KEY,
Expand Down
27 changes: 19 additions & 8 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
# Studiqo HTTP API base URL (must include /api/v1).
# Local default matches apps/api when PORT=3000.
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/api/v1

# `npm run dev -w apps/web` uses port 3001 (see apps/web/package.json) so the API can use 3000.
# App shell origin (login, onboarding). Required for redirects from tenant hosts in dev.
# Match `npm run dev` port (see apps/web/package.json).
NEXT_PUBLIC_APP_SHELL_ORIGIN=http://localhost:3002

# Local dev: the API and Next.js both default to port 3000 — pick one:
# - Run API on 3000 and Next with: next dev -p 3001
# Then add http://localhost:3001 (and http://127.0.0.1:3001) to the API's
# CORS_ALLOWED_ORIGINS in .env (see repo root .env.example).
# - Or use a same-origin proxy (e.g. Next rewrites) so the browser only talks
# to one origin; verify HttpOnly refresh cookies still work for your setup.
# DNS root for tenant subdomains (no port). Production: studiqo.io. Local subdomains: localhost
NEXT_PUBLIC_ROOT_DOMAIN=localhost

# Auth entry subdomain when using real DNS (ignored for single-host dev below).
NEXT_PUBLIC_APP_SUBDOMAIN=app

# http locally, https in production
NEXT_PUBLIC_WEB_PROTOCOL=http

# Port appended to tenant URLs when using *.localhost (e.g. http://acme.localhost:3002).
NEXT_PUBLIC_WEB_PORT=3002

# Single-host dev: tenant workspace at http://localhost:3002/t/{org-slug}/...
NEXT_PUBLIC_TENANT_PATH_ROUTING=true

# API runs on PORT=3000 by default; add this web origin to API CORS_ALLOWED_ORIGINS (repo root .env.example).
8 changes: 8 additions & 0 deletions apps/web/app/(app)/invites/[token]/accept/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { InviteAcceptLoader } from "@/components/invite-flow";

type PageProps = { params: Promise<{ token: string }> };

export default async function InviteAcceptPage({ params }: PageProps) {
const { token } = await params;
return <InviteAcceptLoader token={token} />;
}
8 changes: 8 additions & 0 deletions apps/web/app/(app)/invites/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { InviteDetailsView } from "@/components/invite-flow";

type PageProps = { params: Promise<{ token: string }> };

export default async function InviteDetailsPage({ params }: PageProps) {
const { token } = await params;
return <InviteDetailsView token={token} />;
}
12 changes: 12 additions & 0 deletions apps/web/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ReactNode } from "react";

import { AppShellHeader } from "@/components/app-shell-header";

export default function AppShellLayout({ children }: { children: ReactNode }) {
return (
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
<AppShellHeader />
{children}
</div>
);
}
110 changes: 110 additions & 0 deletions apps/web/app/(app)/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { isStudiqoApiError } from "@studiqo/api-client/errors";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";

import { useSession } from "@/lib/auth/session";
import { loginFormSchema } from "@/lib/validation/auth-forms";

type FormValues = { email: string; password: string };

function safeReturnPath(raw: string | null): string {
if (!raw) return "/onboarding";
if (!raw.startsWith("/") || raw.startsWith("//")) return "/onboarding";
return raw;
}

export function LoginForm() {
const router = useRouter();
const params = useSearchParams();
const { authStatus, loginWithPassword } = useSession();
const [error, setError] = useState<string | null>(null);
const returnUrl = safeReturnPath(params.get("returnUrl"));

const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
} = useForm<FormValues>({
resolver: zodResolver(loginFormSchema),
defaultValues: { email: "", password: "" },
});

useEffect(() => {
const email = params.get("email");
if (email) setValue("email", email);
}, [params, setValue]);

useEffect(() => {
if (authStatus === "authenticated") {
router.replace(returnUrl);
}
}, [authStatus, router, returnUrl]);

async function onSubmit(values: FormValues) {
setError(null);
try {
await loginWithPassword(values.email, values.password);
router.replace(returnUrl);
} catch (e) {
if (isStudiqoApiError(e)) {
setError(e.message);
return;
}
setError("Something went wrong");
}
}

if (authStatus === "loading") {
return <p style={{ padding: 24 }}>Checking session…</p>;
}

return (
<main style={{ padding: "1.5rem", maxWidth: 400 }}>
<h1>Log in</h1>
{params.get("registered") ? (
<p style={{ fontSize: 14, color: "#166534" }}>Account created. Sign in below.</p>
) : null}
<form
onSubmit={handleSubmit(onSubmit)}
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span>Email</span>
<input
type="email"
autoComplete="email"
{...register("email")}
style={{ padding: 8 }}
/>
{errors.email ? (
<span style={{ color: "#b91c1c", fontSize: 12 }}>{errors.email.message}</span>
) : null}
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span>Password</span>
<input
type="password"
autoComplete="current-password"
{...register("password")}
style={{ padding: 8 }}
/>
{errors.password ? (
<span style={{ color: "#b91c1c", fontSize: 12 }}>{errors.password.message}</span>
) : null}
</label>
{error ? <p style={{ color: "#b91c1c", fontSize: 14 }}>{error}</p> : null}
<button type="submit" disabled={isSubmitting} style={{ padding: "10px 16px" }}>
{isSubmitting ? "Signing in…" : "Sign in"}
</button>
</form>
<p style={{ marginTop: 16, fontSize: 14 }}>
<a href="/register">Create an account</a>
</p>
</main>
);
}
11 changes: 11 additions & 0 deletions apps/web/app/(app)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Suspense } from "react";

import { LoginForm } from "./login-form";

export default function LoginPage() {
return (
<Suspense fallback={<p style={{ padding: 24 }}>Loading…</p>}>
<LoginForm />
</Suspense>
);
}
142 changes: 142 additions & 0 deletions apps/web/app/(app)/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { isStudiqoApiError } from "@studiqo/api-client/errors";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";

import {
useCreateOrganizationMutation,
useOrganizationsQuery,
useSetActiveOrganizationMutation,
} from "@/lib/api/organizations-query";
import { useSession } from "@/lib/auth/session";
import { tenantWorkspaceUrl } from "@/lib/urls";
import { createOrganizationFormSchema } from "@/lib/validation/auth-forms";

type OrgForm = { name: string; slug: string };

export default function OnboardingPage() {
const router = useRouter();
const { authStatus } = useSession();
const { data: orgs, isLoading: orgsLoading } = useOrganizationsQuery();
const createOrg = useCreateOrganizationMutation();
const setActive = useSetActiveOrganizationMutation();
const [error, setError] = useState<string | null>(null);

const form = useForm<OrgForm>({
resolver: zodResolver(createOrganizationFormSchema),
defaultValues: { name: "", slug: "" },
});

useEffect(() => {
if (authStatus === "unauthenticated") {
router.replace("/login?returnUrl=/onboarding");
}
}, [authStatus, router]);

async function enterOrg(organizationId: string, slug: string) {
setError(null);
try {
await setActive.mutateAsync(organizationId);
window.location.href = tenantWorkspaceUrl(slug);
} catch (e) {
if (isStudiqoApiError(e)) setError(e.message);
else setError("Could not switch organization");
}
}

async function onCreate(values: OrgForm) {
setError(null);
try {
const org = await createOrg.mutateAsync(values);
await setActive.mutateAsync(org.id);
window.location.href = tenantWorkspaceUrl(org.slug);
} catch (e) {
if (isStudiqoApiError(e)) setError(e.message);
else setError("Could not create organization");
}
}

if (authStatus === "loading") {
return <p style={{ padding: 24 }}>Loading…</p>;
}

if (authStatus === "unauthenticated") {
return null;
}

return (
<main style={{ padding: "1.5rem", maxWidth: 520 }}>
<h1>Organizations</h1>
<p style={{ fontSize: 14, opacity: 0.85 }}>
Choose an organization or create one. You will be redirected to its workspace.
</p>
{orgsLoading ? (
<p>Loading organizations…</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{(orgs ?? []).map((o) => (
<li
key={o.id}
style={{
padding: "12px 0",
borderBottom: "1px solid #eee",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<div>
<strong>{o.name}</strong>
<div style={{ fontSize: 13, opacity: 0.75 }}>{o.slug}</div>
</div>
<button
type="button"
disabled={setActive.isPending}
onClick={() => void enterOrg(o.id, o.slug)}
style={{ padding: "8px 14px" }}
>
Open
</button>
</li>
))}
</ul>
)}
<h2 style={{ marginTop: 32, fontSize: 18 }}>Create organization</h2>
<form
onSubmit={form.handleSubmit(onCreate)}
style={{ display: "flex", flexDirection: "column", gap: 12, maxWidth: 360 }}
>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span>Name</span>
<input {...form.register("name")} style={{ padding: 8 }} />
{form.formState.errors.name ? (
<span style={{ color: "#b91c1c", fontSize: 12 }}>
{form.formState.errors.name.message}
</span>
) : null}
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span>Slug (URL)</span>
<input {...form.register("slug")} style={{ padding: 8 }} />
{form.formState.errors.slug ? (
<span style={{ color: "#b91c1c", fontSize: 12 }}>
{form.formState.errors.slug.message}
</span>
) : null}
</label>
{error ? <p style={{ color: "#b91c1c", fontSize: 14 }}>{error}</p> : null}
<button
type="submit"
disabled={createOrg.isPending || setActive.isPending}
style={{ padding: "10px 16px" }}
>
{createOrg.isPending || setActive.isPending ? "Saving…" : "Create and open"}
</button>
</form>
</main>
);
}
6 changes: 3 additions & 3 deletions apps/web/app/page.tsx → apps/web/app/(app)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { DevHealthStatus } from "./dev-health-status";
import { DevHealthStatus } from "../dev-health-status";

import { formatIsoDateTime } from "@/lib/datetime";

const sampleApiDateTime = "2026-04-01T14:00:00.000Z";

export default function Page() {
export default function AppHomePage() {
return (
<main style={{ padding: "1.5rem", maxWidth: 640 }}>
<h1>Studiqo</h1>
<p>Next.js workspace scaffold with Phase 0 providers and API client.</p>
<p>App shell — sign in, create an organization, or open your workspace.</p>
<p style={{ fontSize: 14, opacity: 0.85 }}>
Sample formatted API date-time: {formatIsoDateTime(sampleApiDateTime)}
</p>
Expand Down
11 changes: 11 additions & 0 deletions apps/web/app/(app)/register/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Suspense } from "react";

import { RegisterForm } from "./register-form";

export default function RegisterPage() {
return (
<Suspense fallback={<p style={{ padding: 24 }}>Loading…</p>}>
<RegisterForm />
</Suspense>
);
}
Loading
Loading