diff --git a/.env.example b/.env.example
index 2a49f77..8879cdd 100644
--- a/.env.example
+++ b/.env.example
@@ -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
@@ -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=
diff --git a/apps/api/src/config/config.ts b/apps/api/src/config/config.ts
index 9e04650..9b7f6f7 100644
--- a/apps/api/src/config/config.ts
+++ b/apps/api/src/config/config.ts
@@ -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",
]),
@@ -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,
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 1a7be82..240b629 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -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).
diff --git a/apps/web/app/(app)/invites/[token]/accept/page.tsx b/apps/web/app/(app)/invites/[token]/accept/page.tsx
new file mode 100644
index 0000000..3029bac
--- /dev/null
+++ b/apps/web/app/(app)/invites/[token]/accept/page.tsx
@@ -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 ;
+}
diff --git a/apps/web/app/(app)/invites/[token]/page.tsx b/apps/web/app/(app)/invites/[token]/page.tsx
new file mode 100644
index 0000000..1ae9376
--- /dev/null
+++ b/apps/web/app/(app)/invites/[token]/page.tsx
@@ -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 ;
+}
diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx
new file mode 100644
index 0000000..b443933
--- /dev/null
+++ b/apps/web/app/(app)/layout.tsx
@@ -0,0 +1,12 @@
+import type { ReactNode } from "react";
+
+import { AppShellHeader } from "@/components/app-shell-header";
+
+export default function AppShellLayout({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/apps/web/app/(app)/login/login-form.tsx b/apps/web/app/(app)/login/login-form.tsx
new file mode 100644
index 0000000..629d69e
--- /dev/null
+++ b/apps/web/app/(app)/login/login-form.tsx
@@ -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(null);
+ const returnUrl = safeReturnPath(params.get("returnUrl"));
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ setValue,
+ } = useForm({
+ 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 Checking session…
;
+ }
+
+ return (
+
+ Log in
+ {params.get("registered") ? (
+ Account created. Sign in below.
+ ) : null}
+
+
+ Create an account
+
+
+ );
+}
diff --git a/apps/web/app/(app)/login/page.tsx b/apps/web/app/(app)/login/page.tsx
new file mode 100644
index 0000000..b80ce4f
--- /dev/null
+++ b/apps/web/app/(app)/login/page.tsx
@@ -0,0 +1,11 @@
+import { Suspense } from "react";
+
+import { LoginForm } from "./login-form";
+
+export default function LoginPage() {
+ return (
+ Loading…}>
+
+
+ );
+}
diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx
new file mode 100644
index 0000000..73c6ce5
--- /dev/null
+++ b/apps/web/app/(app)/onboarding/page.tsx
@@ -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(null);
+
+ const form = useForm({
+ 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 Loading…
;
+ }
+
+ if (authStatus === "unauthenticated") {
+ return null;
+ }
+
+ return (
+
+ Organizations
+
+ Choose an organization or create one. You will be redirected to its workspace.
+
+ {orgsLoading ? (
+ Loading organizations…
+ ) : (
+
+ {(orgs ?? []).map((o) => (
+
+
+ void enterOrg(o.id, o.slug)}
+ style={{ padding: "8px 14px" }}
+ >
+ Open
+
+
+ ))}
+
+ )}
+ Create organization
+
+
+ );
+}
diff --git a/apps/web/app/page.tsx b/apps/web/app/(app)/page.tsx
similarity index 68%
rename from apps/web/app/page.tsx
rename to apps/web/app/(app)/page.tsx
index f556333..69db2aa 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/(app)/page.tsx
@@ -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 (
Studiqo
- Next.js workspace scaffold with Phase 0 providers and API client.
+ App shell — sign in, create an organization, or open your workspace.
Sample formatted API date-time: {formatIsoDateTime(sampleApiDateTime)}
diff --git a/apps/web/app/(app)/register/page.tsx b/apps/web/app/(app)/register/page.tsx
new file mode 100644
index 0000000..7397932
--- /dev/null
+++ b/apps/web/app/(app)/register/page.tsx
@@ -0,0 +1,11 @@
+import { Suspense } from "react";
+
+import { RegisterForm } from "./register-form";
+
+export default function RegisterPage() {
+ return (
+ Loading…}>
+
+
+ );
+}
diff --git a/apps/web/app/(app)/register/register-form.tsx b/apps/web/app/(app)/register/register-form.tsx
new file mode 100644
index 0000000..a6413c8
--- /dev/null
+++ b/apps/web/app/(app)/register/register-form.tsx
@@ -0,0 +1,98 @@
+"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 { useSession } from "@/lib/auth/session";
+import { registerFormSchema } from "@/lib/validation/auth-forms";
+
+type FormValues = { email: string; password: string };
+
+export function RegisterForm() {
+ const router = useRouter();
+ const { authStatus, registerAccount } = useSession();
+ const [error, setError] = useState(null);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(registerFormSchema),
+ defaultValues: { email: "", password: "" },
+ });
+
+ useEffect(() => {
+ if (authStatus === "authenticated") {
+ router.replace("/onboarding");
+ }
+ }, [authStatus, router]);
+
+ async function onSubmit(values: FormValues) {
+ setError(null);
+ try {
+ await registerAccount(values.email, values.password);
+ router.push(
+ `/login?registered=1&email=${encodeURIComponent(values.email)}`,
+ );
+ } catch (e) {
+ if (isStudiqoApiError(e)) {
+ setError(e.message);
+ return;
+ }
+ setError("Something went wrong");
+ }
+ }
+
+ if (authStatus === "loading") {
+ return Checking session…
;
+ }
+
+ return (
+
+ Register
+
+ Creates your account only. You will create or join an organization next.
+
+
+
+ Already have an account?
+
+
+ );
+}
diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx
index 81fe5cc..be78a17 100644
--- a/apps/web/app/providers.tsx
+++ b/apps/web/app/providers.tsx
@@ -6,7 +6,14 @@ import { useState, type ReactNode } from "react";
import { SessionProvider } from "@/lib/auth/session";
export function Providers({ children }: { children: ReactNode }) {
- const [queryClient] = useState(() => new QueryClient());
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ }),
+ );
return (
diff --git a/apps/web/app/t/[tenantSlug]/(public)/invites/[token]/accept/page.tsx b/apps/web/app/t/[tenantSlug]/(public)/invites/[token]/accept/page.tsx
new file mode 100644
index 0000000..1674501
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/(public)/invites/[token]/accept/page.tsx
@@ -0,0 +1,10 @@
+import { InviteAcceptLoader } from "@/components/invite-flow";
+
+type PageProps = {
+ params: Promise<{ tenantSlug: string; token: string }>;
+};
+
+export default async function TenantInviteAcceptPage({ params }: PageProps) {
+ const { tenantSlug, token } = await params;
+ return ;
+}
diff --git a/apps/web/app/t/[tenantSlug]/(public)/invites/[token]/page.tsx b/apps/web/app/t/[tenantSlug]/(public)/invites/[token]/page.tsx
new file mode 100644
index 0000000..c0aea41
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/(public)/invites/[token]/page.tsx
@@ -0,0 +1,10 @@
+import { InviteDetailsView } from "@/components/invite-flow";
+
+type PageProps = {
+ params: Promise<{ tenantSlug: string; token: string }>;
+};
+
+export default async function TenantInviteDetailsPage({ params }: PageProps) {
+ const { tenantSlug, token } = await params;
+ return ;
+}
diff --git a/apps/web/app/t/[tenantSlug]/(public)/layout.tsx b/apps/web/app/t/[tenantSlug]/(public)/layout.tsx
new file mode 100644
index 0000000..2613295
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/(public)/layout.tsx
@@ -0,0 +1,8 @@
+import type { ReactNode } from "react";
+
+/** Invitation flows are public (no session required). */
+export default function TenantPublicLayout({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ );
+}
diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/invites/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/invites/page.tsx
new file mode 100644
index 0000000..eba5510
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/(workspace)/invites/page.tsx
@@ -0,0 +1,5 @@
+import { TenantInvitesPage } from "./tenant-invites-page";
+
+export default function InvitesRoutePage() {
+ return ;
+}
diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/invites/tenant-invites-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/invites/tenant-invites-page.tsx
new file mode 100644
index 0000000..094ff18
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/(workspace)/invites/tenant-invites-page.tsx
@@ -0,0 +1,248 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { isStudiqoApiError } from "@studiqo/api-client/errors";
+import { useParams } from "next/navigation";
+import { useMemo, useState } from "react";
+import { useForm } from "react-hook-form";
+
+import {
+ useCreateOrganizationInvitationMutation,
+ useOrganizationInvitationsQuery,
+ useResendOrganizationInvitationMutation,
+ useRevokeOrganizationInvitationMutation,
+} from "@/lib/api/organization-invitations-query";
+import { useOrganizationsQuery } from "@/lib/api/organizations-query";
+import { useSession } from "@/lib/auth/session";
+import { formatIsoDateTime } from "@/lib/datetime";
+import { inviteParentEmailSchema } from "@/lib/validation/auth-forms";
+
+type InviteForm = { email: string };
+
+function invitationStatus(row: {
+ acceptedAt: string | null;
+ revokedAt: string | null;
+ expiresAt: string;
+}): "accepted" | "revoked" | "expired" | "pending" {
+ if (row.acceptedAt) return "accepted";
+ if (row.revokedAt) return "revoked";
+ if (new Date(row.expiresAt) < new Date()) return "expired";
+ return "pending";
+}
+
+export function TenantInvitesPage() {
+ const params = useParams<{ tenantSlug: string }>();
+ const tenantSlug = params.tenantSlug;
+ const { user } = useSession();
+ const { data: orgs, isLoading: orgsLoading } = useOrganizationsQuery();
+
+ const organizationId = useMemo(() => {
+ return orgs?.find((o) => o.slug === tenantSlug)?.id ?? null;
+ }, [orgs, tenantSlug]);
+
+ const canManageInvites =
+ user?.role === "org_admin" || user?.isSuperadmin === true;
+
+ const { data: invitations, isLoading: invitesLoading, error: invitesError } =
+ useOrganizationInvitationsQuery(canManageInvites ? organizationId : null);
+
+ const createInvite = useCreateOrganizationInvitationMutation(
+ organizationId ?? "",
+ );
+ const resendInvite = useResendOrganizationInvitationMutation(
+ organizationId ?? "",
+ );
+ const revokeInvite = useRevokeOrganizationInvitationMutation(
+ organizationId ?? "",
+ );
+
+ const [formError, setFormError] = useState(null);
+
+ const form = useForm({
+ resolver: zodResolver(inviteParentEmailSchema),
+ defaultValues: { email: "" },
+ });
+
+ if (!canManageInvites) {
+ return (
+
+ Parent invitations
+
+ Only organization admins can invite parents. Contact an admin if you
+ need access for a family member.
+
+
+ );
+ }
+
+ if (orgsLoading) {
+ return (
+
+ Parent invitations
+ Loading…
+
+ );
+ }
+
+ if (!organizationId) {
+ return (
+
+ Parent invitations
+
+ This workspace does not match an organization in your account.
+
+
+ );
+ }
+
+ async function onInviteSubmit(values: InviteForm) {
+ setFormError(null);
+ try {
+ await createInvite.mutateAsync({ email: values.email });
+ form.reset();
+ } catch (e) {
+ if (isStudiqoApiError(e)) setFormError(e.message);
+ else setFormError("Could not send invitation");
+ }
+ }
+
+ const listError =
+ invitesError && isStudiqoApiError(invitesError)
+ ? invitesError.message
+ : invitesError
+ ? "Could not load invitations"
+ : null;
+
+ return (
+
+ Parent invitations
+
+ Send email invitations for parents to join this organization. Each
+ invitee sets their password when they accept the link.
+
+
+
+
+
+ Invitations
+ {listError ? (
+ {listError}
+ ) : invitesLoading ? (
+ Loading invitations…
+ ) : (invitations?.length ?? 0) === 0 ? (
+ No invitations yet.
+ ) : (
+
+ {invitations!.map((inv) => {
+ const status = invitationStatus(inv);
+ const canAct = status === "pending";
+ return (
+
+
+ {inv.email}
+
+ {status === "pending"
+ ? "Pending"
+ : status === "accepted"
+ ? "Accepted"
+ : status === "revoked"
+ ? "Revoked"
+ : "Expired"}
+
+
+
+ Expires {formatIsoDateTime(inv.expiresAt)} · Role{" "}
+ {inv.role}
+
+ {canAct ? (
+
+ {
+ void resendInvite.mutateAsync(inv.id);
+ }}
+ >
+ Resend
+
+ {
+ void revokeInvite.mutateAsync(inv.id);
+ }}
+ >
+ Revoke
+
+
+ ) : null}
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/layout.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/layout.tsx
new file mode 100644
index 0000000..7144fdf
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/(workspace)/layout.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+
+import { TenantAccessGate } from "../tenant-access-gate";
+import { TenantChrome } from "../tenant-chrome";
+
+export default async function TenantWorkspaceLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ tenantSlug: string }>;
+}) {
+ const { tenantSlug } = await params;
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/page.tsx
new file mode 100644
index 0000000..6d564fa
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/(workspace)/page.tsx
@@ -0,0 +1,13 @@
+type PageProps = { params: Promise<{ tenantSlug: string }> };
+
+export default async function TenantHomePage({ params }: PageProps) {
+ const { tenantSlug } = await params;
+ return (
+
+ Workspace
+
+ You are in {tenantSlug} . Student and lesson tools arrive in Phase 2 and 3.
+
+
+ );
+}
diff --git a/apps/web/app/t/[tenantSlug]/layout.tsx b/apps/web/app/t/[tenantSlug]/layout.tsx
new file mode 100644
index 0000000..e30fbaf
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/layout.tsx
@@ -0,0 +1,6 @@
+import type { ReactNode } from "react";
+
+/** Pass-through: workspace shell lives in `(workspace)`; public invite routes in `(public)`. */
+export default function TenantRootLayout({ children }: { children: ReactNode }) {
+ return children;
+}
diff --git a/apps/web/app/t/[tenantSlug]/tenant-access-gate.tsx b/apps/web/app/t/[tenantSlug]/tenant-access-gate.tsx
new file mode 100644
index 0000000..64ac8e5
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/tenant-access-gate.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useEffect } from "react";
+
+import { useOrganizationsQuery } from "@/lib/api/organizations-query";
+import { useSession } from "@/lib/auth/session";
+import { appShellUrl } from "@/lib/urls";
+
+export function TenantAccessGate({
+ tenantSlug,
+ children,
+}: {
+ tenantSlug: string;
+ children: React.ReactNode;
+}) {
+ const { authStatus, user } = useSession();
+ const { data: orgs, isLoading: orgsLoading } = useOrganizationsQuery();
+
+ useEffect(() => {
+ if (authStatus !== "unauthenticated") return;
+ const returnUrl =
+ typeof window !== "undefined" ? window.location.href : "";
+ const q = returnUrl
+ ? `?returnUrl=${encodeURIComponent(returnUrl)}`
+ : "";
+ window.location.href = appShellUrl(`/login${q}`);
+ }, [authStatus]);
+
+ if (authStatus === "loading") {
+ return Loading session…
;
+ }
+
+ if (authStatus === "unauthenticated") {
+ return Redirecting to log in…
;
+ }
+
+ if (orgsLoading) {
+ return Loading workspace…
;
+ }
+
+ const list = orgs ?? [];
+ const allowed =
+ user?.isSuperadmin === true ||
+ list.some((o) => o.slug === tenantSlug);
+
+ if (!allowed) {
+ return (
+
+ Workspace not found
+ You do not have access to this organization.
+
+ Manage organizations
+
+
+ );
+ }
+
+ return children;
+}
diff --git a/apps/web/app/t/[tenantSlug]/tenant-chrome.tsx b/apps/web/app/t/[tenantSlug]/tenant-chrome.tsx
new file mode 100644
index 0000000..b02e176
--- /dev/null
+++ b/apps/web/app/t/[tenantSlug]/tenant-chrome.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { TenantNav } from "@/components/tenant-nav";
+import { useSession } from "@/lib/auth/session";
+import { appShellUrl } from "@/lib/urls";
+
+export function TenantChrome({
+ tenantSlug,
+ children,
+}: {
+ tenantSlug: string;
+ children: React.ReactNode;
+}) {
+ const { user, logout } = useSession();
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/app-shell-header.tsx b/apps/web/components/app-shell-header.tsx
new file mode 100644
index 0000000..a7541a2
--- /dev/null
+++ b/apps/web/components/app-shell-header.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import Link from "next/link";
+
+import { useSession } from "@/lib/auth/session";
+import { appShellUrl } from "@/lib/urls";
+
+export function AppShellHeader() {
+ const { user, authStatus, logout } = useSession();
+ const authed = authStatus === "authenticated";
+ const loading = authStatus === "loading";
+
+ return (
+
+
+
+ Studiqo
+
+
+ Organizations
+ {loading ? null : authed ? null : (
+ <>
+ Log in
+ Register
+ >
+ )}
+
+
+ {loading ? (
+ Session…
+ ) : authed ? (
+
+
+ {user?.email ?? "—"}
+ {user?.role ? ` · ${user.role}` : null}
+ {user?.isSuperadmin ? " · superadmin" : null}
+
+ {
+ void (async () => {
+ await logout();
+ window.location.href = appShellUrl("/login");
+ })();
+ }}
+ >
+ Log out
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/components/invite-flow.tsx b/apps/web/components/invite-flow.tsx
new file mode 100644
index 0000000..a60f308
--- /dev/null
+++ b/apps/web/components/invite-flow.tsx
@@ -0,0 +1,236 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { isStudiqoApiError } from "@studiqo/api-client/errors";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+
+import {
+ acceptInvitationRequest,
+ fetchInvitationDetails,
+} from "@/lib/api/invitations-public";
+import { useSession } from "@/lib/auth/session";
+import { formatIsoDateTime } from "@/lib/datetime";
+import { appShellUrl, tenantWorkspaceUrl } from "@/lib/urls";
+import { acceptInviteFormSchema } from "@/lib/validation/auth-forms";
+
+import type { components } from "@studiqo/api-client/generated";
+
+type InvitationDetails = components["schemas"]["InvitationDetails"];
+
+type AcceptForm = { password: string };
+
+export function InviteDetailsView({
+ token,
+ expectedSlug,
+}: {
+ token: string;
+ expectedSlug?: string;
+}) {
+ const [details, setDetails] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const d = await fetchInvitationDetails(token);
+ if (!cancelled) setDetails(d);
+ } catch (e) {
+ if (!cancelled) {
+ if (isStudiqoApiError(e)) setError(e.message);
+ else setError("Could not load invitation");
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [token]);
+
+ if (error) {
+ return (
+
+ Invitation
+ {error}
+
+ Back to log in
+
+
+ );
+ }
+
+ if (!details) {
+ return Loading invitation…
;
+ }
+
+ if (expectedSlug && details.organizationSlug !== expectedSlug) {
+ return (
+
+ Invitation
+ This invitation belongs to another organization workspace.
+
+ Go to the correct workspace
+
+
+ );
+ }
+
+ const acceptPath = expectedSlug
+ ? `/t/${expectedSlug}/invites/${token}/accept`
+ : `/invites/${token}/accept`;
+
+ return (
+
+ Invitation
+
+ You are invited to {details.organizationName} as a{" "}
+ {details.role} .
+
+
+ Email: {details.email}
+
+ Expires: {formatIsoDateTime(details.expiresAt)}
+
+
+ Continue to accept
+
+
+ );
+}
+
+export function InviteAcceptForm({
+ token,
+ organizationSlug,
+}: {
+ token: string;
+ organizationSlug: string;
+}) {
+ const { setAccessToken, refetchUser } = useSession();
+ const [error, setError] = useState(null);
+ const [exists, setExists] = useState(false);
+
+ const form = useForm({
+ resolver: zodResolver(acceptInviteFormSchema),
+ defaultValues: { password: "" },
+ });
+
+ async function onSubmit(values: AcceptForm) {
+ setError(null);
+ setExists(false);
+ try {
+ const data = await acceptInvitationRequest(token, values.password);
+ setAccessToken(data.token);
+ await refetchUser();
+ window.location.href = tenantWorkspaceUrl(organizationSlug);
+ } catch (e) {
+ if (isStudiqoApiError(e)) {
+ if (e.status === 409) {
+ setExists(true);
+ return;
+ }
+ setError(e.message);
+ return;
+ }
+ setError("Something went wrong");
+ }
+ }
+
+ if (exists) {
+ return (
+
+ Account exists
+ An account already uses this email. Log in to join the organization.
+
+ Log in
+
+
+ );
+ }
+
+ return (
+
+ Set your password
+
+ Create a password for your parent account at {organizationSlug} .
+
+
+
+ );
+}
+
+export function InviteAcceptLoader({
+ token,
+ expectedSlug,
+}: {
+ token: string;
+ expectedSlug?: string;
+}) {
+ const [slug, setSlug] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const d = await fetchInvitationDetails(token);
+ if (cancelled) return;
+ if (expectedSlug && d.organizationSlug !== expectedSlug) {
+ setError("wrong-tenant");
+ return;
+ }
+ setSlug(d.organizationSlug);
+ } catch (e) {
+ if (!cancelled) {
+ if (isStudiqoApiError(e)) setError(e.message);
+ else setError("load");
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [token, expectedSlug]);
+
+ if (error === "wrong-tenant") {
+ return (
+
+ Wrong workspace
+ This invitation is not for this organization URL.
+ Log in
+
+ );
+ }
+
+ if (error && error !== "wrong-tenant") {
+ return (
+
+ Invitation
+ {error}
+
+ );
+ }
+
+ if (!slug) {
+ return Loading…
;
+ }
+
+ return ;
+}
diff --git a/apps/web/components/tenant-nav.tsx b/apps/web/components/tenant-nav.tsx
new file mode 100644
index 0000000..80b7c9d
--- /dev/null
+++ b/apps/web/components/tenant-nav.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import Link from "next/link";
+
+import type { components } from "@studiqo/api-client/generated";
+
+type Role = components["schemas"]["OrganizationMembershipRole"] | undefined;
+
+export function TenantNav({
+ tenantSlug,
+ role,
+ isSuperadmin,
+}: {
+ tenantSlug: string;
+ role: Role;
+ isSuperadmin: boolean;
+}) {
+ const base = `/t/${tenantSlug}`;
+ const showStaffLinks =
+ role === "org_admin" || role === "tutor" || isSuperadmin;
+
+ return (
+
+ Home
+ {showStaffLinks ? (
+ Students (Phase 2)
+ ) : null}
+ {role === "org_admin" || isSuperadmin ? (
+ Invites
+ ) : null}
+ {role === "org_admin" || isSuperadmin ? (
+ More admin (Phase 4)
+ ) : null}
+
+ );
+}
diff --git a/apps/web/lib/api/invitations-public.ts b/apps/web/lib/api/invitations-public.ts
new file mode 100644
index 0000000..0686256
--- /dev/null
+++ b/apps/web/lib/api/invitations-public.ts
@@ -0,0 +1,25 @@
+import { createStudiqoClient } from "@studiqo/api-client/client";
+import { unwrapStudiqoResponse } from "@studiqo/api-client/errors";
+
+import { getPublicApiBaseUrl } from "@/lib/env";
+
+function publicClient() {
+ return createStudiqoClient(getPublicApiBaseUrl());
+}
+
+export async function fetchInvitationDetails(token: string) {
+ const client = publicClient();
+ const r = await client.GET("/invites/{token}", {
+ params: { path: { token } },
+ });
+ return unwrapStudiqoResponse(r);
+}
+
+export async function acceptInvitationRequest(token: string, password: string) {
+ const client = publicClient();
+ const r = await client.POST("/invites/{token}/accept", {
+ params: { path: { token } },
+ body: { password },
+ });
+ return unwrapStudiqoResponse(r);
+}
diff --git a/apps/web/lib/api/organization-invitations-query.ts b/apps/web/lib/api/organization-invitations-query.ts
new file mode 100644
index 0000000..b64bf2b
--- /dev/null
+++ b/apps/web/lib/api/organization-invitations-query.ts
@@ -0,0 +1,93 @@
+"use client";
+
+import { unwrapStudiqoResponse } from "@studiqo/api-client/errors";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { useSession } from "@/lib/auth/session";
+
+import { organizationQueryKey } from "./organizations-query";
+
+export function organizationInvitationsQueryKey(organizationId: string) {
+ return ["organizations", organizationId, "invites"] as const;
+}
+
+export function useOrganizationInvitationsQuery(organizationId: string | null) {
+ const { apiClient, authStatus, accessToken } = useSession();
+ return useQuery({
+ queryKey: organizationId
+ ? organizationInvitationsQueryKey(organizationId)
+ : ["organization-invites", "disabled"],
+ queryFn: async () => {
+ const r = await apiClient.GET("/organizations/{organizationId}/invites", {
+ params: { path: { organizationId: organizationId! } },
+ });
+ return unwrapStudiqoResponse(r);
+ },
+ enabled:
+ Boolean(organizationId) &&
+ authStatus === "authenticated" &&
+ Boolean(accessToken),
+ });
+}
+
+export function useCreateOrganizationInvitationMutation(organizationId: string) {
+ const { apiClient } = useSession();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (body: { email: string }) => {
+ const r = await apiClient.POST("/organizations/{organizationId}/invites", {
+ params: { path: { organizationId } },
+ body,
+ });
+ return unwrapStudiqoResponse(r);
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: organizationInvitationsQueryKey(organizationId),
+ });
+ void queryClient.invalidateQueries({ queryKey: organizationQueryKey });
+ },
+ });
+}
+
+export function useResendOrganizationInvitationMutation(organizationId: string) {
+ const { apiClient } = useSession();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (invitationId: string) => {
+ const r = await apiClient.POST(
+ "/organizations/{organizationId}/invites/{invitationId}/resend",
+ {
+ params: { path: { organizationId, invitationId } },
+ },
+ );
+ return unwrapStudiqoResponse(r);
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: organizationInvitationsQueryKey(organizationId),
+ });
+ },
+ });
+}
+
+export function useRevokeOrganizationInvitationMutation(organizationId: string) {
+ const { apiClient } = useSession();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (invitationId: string) => {
+ const r = await apiClient.POST(
+ "/organizations/{organizationId}/invites/{invitationId}/revoke",
+ {
+ params: { path: { organizationId, invitationId } },
+ },
+ );
+ return unwrapStudiqoResponse(r);
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: organizationInvitationsQueryKey(organizationId),
+ });
+ },
+ });
+}
diff --git a/apps/web/lib/api/organizations-query.ts b/apps/web/lib/api/organizations-query.ts
new file mode 100644
index 0000000..fefa0e4
--- /dev/null
+++ b/apps/web/lib/api/organizations-query.ts
@@ -0,0 +1,52 @@
+"use client";
+
+import { unwrapStudiqoResponse } from "@studiqo/api-client/errors";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { useSession } from "@/lib/auth/session";
+
+export const organizationQueryKey = ["organizations"] as const;
+
+export function useOrganizationsQuery() {
+ const { apiClient, accessToken, authStatus } = useSession();
+ return useQuery({
+ queryKey: organizationQueryKey,
+ queryFn: async () => {
+ const r = await apiClient.GET("/organizations");
+ return unwrapStudiqoResponse(r);
+ },
+ enabled: authStatus === "authenticated" && Boolean(accessToken),
+ });
+}
+
+export function useCreateOrganizationMutation() {
+ const { apiClient } = useSession();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (body: { name: string; slug: string }) => {
+ const r = await apiClient.POST("/organizations", { body });
+ return unwrapStudiqoResponse(r);
+ },
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: organizationQueryKey });
+ },
+ });
+}
+
+export function useSetActiveOrganizationMutation() {
+ const { apiClient, setAccessToken, refetchUser } = useSession();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (organizationId: string) => {
+ const r = await apiClient.POST("/auth/active-organization", {
+ body: { organizationId },
+ });
+ return unwrapStudiqoResponse(r);
+ },
+ onSuccess: async (data) => {
+ setAccessToken(data.token);
+ await refetchUser();
+ void queryClient.invalidateQueries({ queryKey: organizationQueryKey });
+ },
+ });
+}
diff --git a/apps/web/lib/auth/session.tsx b/apps/web/lib/auth/session.tsx
index 031048b..3aaf61d 100644
--- a/apps/web/lib/auth/session.tsx
+++ b/apps/web/lib/auth/session.tsx
@@ -5,10 +5,12 @@ import {
unwrapStudiqoResponse,
unwrapStudiqoVoid,
} from "@studiqo/api-client/errors";
+import type { components } from "@studiqo/api-client/generated";
import {
createContext,
useCallback,
useContext,
+ useEffect,
useMemo,
useRef,
useState,
@@ -17,18 +19,30 @@ import {
import { getPublicApiBaseUrl } from "@/lib/env";
+export type UserPublic = components["schemas"]["UserPublic"];
+
+export type AuthStatus = "loading" | "authenticated" | "unauthenticated";
+
export type SessionContextValue = {
+ apiClient: ReturnType;
accessToken: string | null;
+ user: UserPublic | null;
+ authStatus: AuthStatus;
setAccessToken: (token: string | null) => void;
clearSession: () => void;
refreshAccessToken: () => Promise;
logout: () => Promise;
+ refetchUser: () => Promise;
+ loginWithPassword: (email: string, password: string) => Promise;
+ registerAccount: (email: string, password: string) => Promise;
};
const SessionContext = createContext(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [accessToken, setAccessTokenState] = useState(null);
+ const [user, setUser] = useState(null);
+ const [authStatus, setAuthStatus] = useState("loading");
const tokenRef = useRef(null);
const setAccessToken = useCallback((token: string | null) => {
@@ -39,6 +53,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const clearSession = useCallback(() => {
tokenRef.current = null;
setAccessTokenState(null);
+ setUser(null);
+ setAuthStatus("unauthenticated");
}, []);
const client = useMemo(
@@ -49,6 +65,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
[],
);
+ const fetchMe = useCallback(async () => {
+ const meResult = await client.GET("/auth/me");
+ const u = unwrapStudiqoResponse(meResult);
+ setUser(u);
+ setAuthStatus("authenticated");
+ }, [client]);
+
+ const refetchUser = useCallback(async () => {
+ if (!tokenRef.current) return;
+ await fetchMe();
+ }, [fetchMe]);
+
const refreshAccessToken = useCallback(async () => {
const result = await client.POST("/auth/refresh");
const data = unwrapStudiqoResponse(result);
@@ -58,23 +86,87 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const logout = useCallback(async () => {
const result = await client.POST("/auth/logout");
unwrapStudiqoVoid(result);
- setAccessToken(null);
- }, [client, setAccessToken]);
+ clearSession();
+ }, [client, clearSession]);
+
+ const loginWithPassword = useCallback(
+ async (email: string, password: string) => {
+ const result = await client.POST("/auth/login", {
+ body: { email, password },
+ });
+ const data = unwrapStudiqoResponse(result);
+ setAccessToken(data.token);
+ await fetchMe();
+ },
+ [client, setAccessToken, fetchMe],
+ );
+
+ const registerAccount = useCallback(
+ async (email: string, password: string) => {
+ await client.POST("/auth/register", {
+ body: { email, password },
+ });
+ },
+ [client],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setAuthStatus("loading");
+ const refreshResult = await client.POST("/auth/refresh");
+ if (cancelled) return;
+ if (!refreshResult.response.ok) {
+ tokenRef.current = null;
+ setAccessTokenState(null);
+ setUser(null);
+ setAuthStatus("unauthenticated");
+ return;
+ }
+ try {
+ const data = unwrapStudiqoResponse(refreshResult);
+ setAccessToken(data.token);
+ const meResult = await client.GET("/auth/me");
+ if (cancelled) return;
+ const u = unwrapStudiqoResponse(meResult);
+ setUser(u);
+ setAuthStatus("authenticated");
+ } catch {
+ if (cancelled) return;
+ clearSession();
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [client, setAccessToken, clearSession]);
const value = useMemo(
() => ({
+ apiClient: client,
accessToken,
+ user,
+ authStatus,
setAccessToken,
clearSession,
refreshAccessToken,
logout,
+ refetchUser,
+ loginWithPassword,
+ registerAccount,
}),
[
+ client,
accessToken,
+ user,
+ authStatus,
setAccessToken,
clearSession,
refreshAccessToken,
logout,
+ refetchUser,
+ loginWithPassword,
+ registerAccount,
],
);
diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts
index 43eb9af..31495d5 100644
--- a/apps/web/lib/env.ts
+++ b/apps/web/lib/env.ts
@@ -4,3 +4,49 @@ export function getPublicApiBaseUrl(): string {
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3000/api/v1";
return raw.replace(/\/$/, "");
}
+
+/** e.g. `studiqo.io` or `localhost` (no port). */
+export function getRootDomain(): string {
+ return (process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "studiqo.io").toLowerCase();
+}
+
+/** Subdomain for auth shell, default `app`. */
+export function getAppSubdomain(): string {
+ return (process.env.NEXT_PUBLIC_APP_SUBDOMAIN ?? "app").toLowerCase();
+}
+
+/** `https` or `http` — use `http` for local dev. */
+export function getWebProtocol(): string {
+ return (process.env.NEXT_PUBLIC_WEB_PROTOCOL ?? "https").replace(/:$/, "");
+}
+
+/** Optional port for tenant URLs, e.g. `3002` → `http://slug.localhost:3002`. */
+export function getWebPortSuffix(): string {
+ const p = process.env.NEXT_PUBLIC_WEB_PORT?.trim();
+ if (!p) return "";
+ return `:${p}`;
+}
+
+/** When `true`, tenant workspace is `/t/[slug]/…` on the app origin (single-host dev). */
+export function isTenantPathRouting(): boolean {
+ return process.env.NEXT_PUBLIC_TENANT_PATH_ROUTING === "true";
+}
+
+/**
+ * Full origin for the app shell (login, onboarding), e.g. `http://localhost:3002`
+ * or `http://app.localhost:3002`. Set in dev so tenant host can redirect here.
+ */
+export function getAppShellOrigin(): string {
+ const explicit = process.env.NEXT_PUBLIC_APP_SHELL_ORIGIN?.trim();
+ if (explicit) {
+ return explicit.replace(/\/$/, "");
+ }
+ const protocol = getWebProtocol();
+ const root = getRootDomain();
+ const appSub = getAppSubdomain();
+ const port = getWebPortSuffix();
+ if (root === "localhost" || root === "127.0.0.1") {
+ return `${protocol}://localhost${port}`;
+ }
+ return `${protocol}://${appSub}.${root}${port}`;
+}
diff --git a/apps/web/lib/tenant-reserved.ts b/apps/web/lib/tenant-reserved.ts
new file mode 100644
index 0000000..08648e5
--- /dev/null
+++ b/apps/web/lib/tenant-reserved.ts
@@ -0,0 +1,9 @@
+import { getAppSubdomain } from "@/lib/env";
+
+/** Hostname labels that cannot be organization tenant slugs. */
+export function isReservedTenantLabel(label: string): boolean {
+ const l = label.toLowerCase();
+ if (l === "www" || l === "api") return true;
+ if (l === getAppSubdomain()) return true;
+ return false;
+}
diff --git a/apps/web/lib/urls.ts b/apps/web/lib/urls.ts
new file mode 100644
index 0000000..e1db3af
--- /dev/null
+++ b/apps/web/lib/urls.ts
@@ -0,0 +1,45 @@
+import {
+ getAppShellOrigin,
+ getAppSubdomain,
+ getRootDomain,
+ getWebPortSuffix,
+ getWebProtocol,
+ isTenantPathRouting,
+} from "@/lib/env";
+
+/** Login, register, onboarding — app shell origin + path. */
+export function appShellUrl(path: string): string {
+ const p = path.startsWith("/") ? path : `/${path}`;
+ if (typeof window !== "undefined") {
+ const { hostname } = window.location;
+ const root = getRootDomain();
+ const onTenantPath =
+ isTenantPathRouting() && window.location.pathname.startsWith("/t/");
+ const onTenantSubdomain =
+ hostname !== "localhost" &&
+ hostname !== "127.0.0.1" &&
+ hostname.endsWith(`.${root}`) &&
+ !hostname.startsWith(`${getAppSubdomain()}.`);
+ if (onTenantPath || onTenantSubdomain) {
+ return `${getAppShellOrigin()}${p}`;
+ }
+ return `${window.location.origin}${p}`;
+ }
+ return `${getAppShellOrigin()}${p}`;
+}
+
+/** Workspace entry for an organization slug (subdomain or `/t/slug`). */
+export function tenantWorkspaceUrl(slug: string): string {
+ const pathSlug = slug.replace(/^\/+|\/+$/g, "");
+ if (isTenantPathRouting()) {
+ const origin =
+ typeof window !== "undefined"
+ ? window.location.origin
+ : getAppShellOrigin();
+ return `${origin}/t/${pathSlug}/`;
+ }
+ const protocol = getWebProtocol();
+ const root = getRootDomain();
+ const port = getWebPortSuffix();
+ return `${protocol}://${pathSlug}.${root}${port}/`;
+}
diff --git a/apps/web/lib/validation/auth-forms.ts b/apps/web/lib/validation/auth-forms.ts
new file mode 100644
index 0000000..60c69a6
--- /dev/null
+++ b/apps/web/lib/validation/auth-forms.ts
@@ -0,0 +1,46 @@
+import { z } from "zod";
+
+const passwordSchema = z
+ .string()
+ .min(8, "Password must be at least 8 characters")
+ .max(100, "Password is too long")
+ .regex(/[A-Z]/, "Password must include an uppercase letter")
+ .regex(/[a-z]/, "Password must include a lowercase letter")
+ .regex(/[0-9]/, "Password must include a number")
+ .regex(/[!@#$%^&*(),.?":{}|<>]/, "Password must include a special character");
+
+export const loginFormSchema = z.object({
+ email: z.string().trim().email("Valid email is required"),
+ password: z.string().min(1, "Password is required"),
+});
+
+export const registerFormSchema = z.object({
+ email: z.string().trim().email("Valid email is required"),
+ password: passwordSchema,
+});
+
+export const createOrganizationFormSchema = z.object({
+ name: z.string().trim().min(1).max(256),
+ slug: z
+ .string()
+ .trim()
+ .min(3)
+ .max(256)
+ .regex(
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
+ "Use lowercase letters, numbers, and single hyphens between segments",
+ ),
+});
+
+export const acceptInviteFormSchema = z.object({
+ password: passwordSchema,
+});
+
+export const inviteTokenParamSchema = z
+ .string()
+ .length(64)
+ .regex(/^[a-f0-9]+$/, "Invalid invitation token");
+
+export const inviteParentEmailSchema = z.object({
+ email: z.string().trim().email("Valid email is required").max(256),
+});
diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts
new file mode 100644
index 0000000..031dfbc
--- /dev/null
+++ b/apps/web/middleware.ts
@@ -0,0 +1,78 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+
+import { isReservedTenantLabel } from "@/lib/tenant-reserved";
+
+function hostnameNoPort(host: string | null): string {
+ if (!host) return "";
+ return host.split(":")[0]?.toLowerCase() ?? "";
+}
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+ if (
+ pathname.startsWith("/_next") ||
+ pathname.startsWith("/favicon") ||
+ /\.[a-z0-9]+$/i.test(pathname)
+ ) {
+ return NextResponse.next();
+ }
+
+ const host = hostnameNoPort(request.headers.get("host"));
+ const rootDomain = (
+ process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "studiqo.io"
+ ).toLowerCase();
+ const appSub = (
+ process.env.NEXT_PUBLIC_APP_SUBDOMAIN ?? "app"
+ ).toLowerCase();
+
+ const reqHeaders = new Headers(request.headers);
+
+ if (process.env.NEXT_PUBLIC_TENANT_PATH_ROUTING === "true") {
+ const m = pathname.match(/^\/t\/([^/]+)(\/.*)?$/);
+ if (m) {
+ const slug = m[1];
+ let rest = m[2] ?? "/";
+ if (rest.startsWith("/invite/")) {
+ rest = `/invites/${rest.slice("/invite/".length)}`;
+ }
+ if (slug && !isReservedTenantLabel(slug)) {
+ reqHeaders.set("x-tenant-slug", slug);
+ const originalRest = m[2] ?? "/";
+ if (rest !== originalRest) {
+ const url = request.nextUrl.clone();
+ url.pathname = `/t/${slug}${rest}`;
+ return NextResponse.rewrite(url, { request: { headers: reqHeaders } });
+ }
+ return NextResponse.next({ request: { headers: reqHeaders } });
+ }
+ }
+ return NextResponse.next();
+ }
+
+ const isLocal = host === "localhost" || host === "127.0.0.1";
+
+ if (!isLocal && host.endsWith(`.${rootDomain}`)) {
+ const sub = host.slice(0, -(`.${rootDomain}`).length);
+ if (sub && !isReservedTenantLabel(sub)) {
+ reqHeaders.set("x-tenant-slug", sub);
+ const url = request.nextUrl.clone();
+ let internalPath = pathname;
+ if (internalPath.startsWith("/invite/")) {
+ internalPath = `/invites/${internalPath.slice("/invite/".length)}`;
+ }
+ url.pathname = `/t/${sub}${internalPath === "/" ? "" : internalPath}`;
+ return NextResponse.rewrite(url, { request: { headers: reqHeaders } });
+ }
+ }
+
+ if (host === `${appSub}.${rootDomain}` || host === rootDomain || isLocal) {
+ return NextResponse.next();
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image).*)"],
+};
diff --git a/apps/web/package.json b/apps/web/package.json
index 1c73549..bc065b8 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,11 +9,14 @@
"lint": "eslint ."
},
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
"@studiqo/api-client": "*",
"@tanstack/react-query": "^5.96.2",
"next": "16.2.2",
"react": "19.1.0",
- "react-dom": "19.1.0"
+ "react-dom": "19.1.0",
+ "react-hook-form": "^7.72.1",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
diff --git a/package-lock.json b/package-lock.json
index 99d98b0..2cc6a40 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -114,10 +114,14 @@
"name": "@studiqo/web",
"version": "0.1.0",
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
+ "@studiqo/api-client": "*",
"@tanstack/react-query": "^5.96.2",
"next": "16.2.2",
"react": "19.1.0",
- "react-dom": "19.1.0"
+ "react-dom": "19.1.0",
+ "react-hook-form": "^7.72.1",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -1768,6 +1772,18 @@
"module-details-from-path": "^1.0.4"
}
},
+ "node_modules/@hookform/resolvers": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
+ "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -4219,6 +4235,12 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@studiqo/api-client": {
"resolved": "packages/api-client",
"link": true
@@ -11352,6 +11374,22 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.72.1",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
+ "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",