From 27ec19effd1ed85754926a19b14d63013d6022ba Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 18:34:51 +0100 Subject: [PATCH 1/8] chore: update CORS settings and invitation path in configuration --- .env.example | 6 +++--- apps/api/src/config/config.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) 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, From b1538e6db9fa0129655aeca11aa59525e28e9531 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 18:55:35 +0100 Subject: [PATCH 2/8] feat: enhance environment configuration and add middleware for tenant routing --- apps/web/.env.example | 27 ++++++++---- apps/web/lib/env.ts | 46 +++++++++++++++++++ apps/web/lib/tenant-reserved.ts | 9 ++++ apps/web/lib/urls.ts | 45 +++++++++++++++++++ apps/web/middleware.ts | 78 +++++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 apps/web/lib/tenant-reserved.ts create mode 100644 apps/web/lib/urls.ts create mode 100644 apps/web/middleware.ts 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/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/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).*)"], +}; From 563aaa2379ae3e037a6c00eab0e740b677867831 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 19:20:29 +0100 Subject: [PATCH 3/8] feat: integrate new API client methods and enhance session management --- apps/web/app/providers.tsx | 9 ++- apps/web/lib/api/invitations-public.ts | 25 +++++++ apps/web/lib/api/organizations-query.ts | 52 ++++++++++++++ apps/web/lib/auth/session.tsx | 96 ++++++++++++++++++++++++- apps/web/package.json | 5 +- package-lock.json | 40 ++++++++++- 6 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 apps/web/lib/api/invitations-public.ts create mode 100644 apps/web/lib/api/organizations-query.ts 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/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/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/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", From b36f93eed111c7cee8dddfda0fe4a710d609a45a Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 19:21:22 +0100 Subject: [PATCH 4/8] feat: add validation schemas for authentication and organization forms --- apps/web/lib/validation/auth-forms.ts | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/web/lib/validation/auth-forms.ts diff --git a/apps/web/lib/validation/auth-forms.ts b/apps/web/lib/validation/auth-forms.ts new file mode 100644 index 0000000..3c193c3 --- /dev/null +++ b/apps/web/lib/validation/auth-forms.ts @@ -0,0 +1,42 @@ +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"); From 625a0255b8b83a94a32fa322cda7dc1981db91b1 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 20:17:39 +0100 Subject: [PATCH 5/8] feat: implement app shell layout and core pages for authentication and onboarding --- .../app/(app)/invites/[token]/accept/page.tsx | 8 + apps/web/app/(app)/invites/[token]/page.tsx | 8 + apps/web/app/(app)/layout.tsx | 27 ++++ apps/web/app/(app)/login/login-form.tsx | 110 ++++++++++++++ apps/web/app/(app)/login/page.tsx | 11 ++ apps/web/app/(app)/onboarding/page.tsx | 142 ++++++++++++++++++ apps/web/app/{ => (app)}/page.tsx | 6 +- apps/web/app/(app)/register/page.tsx | 11 ++ apps/web/app/(app)/register/register-form.tsx | 98 ++++++++++++ 9 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(app)/invites/[token]/accept/page.tsx create mode 100644 apps/web/app/(app)/invites/[token]/page.tsx create mode 100644 apps/web/app/(app)/layout.tsx create mode 100644 apps/web/app/(app)/login/login-form.tsx create mode 100644 apps/web/app/(app)/login/page.tsx create mode 100644 apps/web/app/(app)/onboarding/page.tsx rename apps/web/app/{ => (app)}/page.tsx (68%) create mode 100644 apps/web/app/(app)/register/page.tsx create mode 100644 apps/web/app/(app)/register/register-form.tsx 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..09b5e17 --- /dev/null +++ b/apps/web/app/(app)/layout.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +export default function AppShellLayout({ children }: { children: ReactNode }) { + return ( +
+
+ + Studiqo + + +
+ {children} +
+ ); +} 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} +
+ + + {error ?

{error}

: 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) => ( +
  • +
    + {o.name} +
    {o.slug}
    +
    + +
  • + ))} +
+ )} +

Create organization

+
+ + + {error ?

{error}

: null} + +
+
+ ); +} 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. +

+
+ + + {error ?

{error}

: null} + +
+

+ Already have an account? +

+
+ ); +} From 99e071cd905d6a7e7948aa56adb34b04f010b148 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 20:35:46 +0100 Subject: [PATCH 6/8] feat: add tenant specific layouts and components for workspace and public invite flows --- .../(public)/invites/[token]/accept/page.tsx | 10 + .../(public)/invites/[token]/page.tsx | 10 + .../app/t/[tenantSlug]/(public)/layout.tsx | 8 + .../app/t/[tenantSlug]/(workspace)/layout.tsx | 19 ++ .../app/t/[tenantSlug]/(workspace)/page.tsx | 13 + apps/web/app/t/[tenantSlug]/layout.tsx | 6 + .../app/t/[tenantSlug]/tenant-access-gate.tsx | 59 +++++ apps/web/app/t/[tenantSlug]/tenant-chrome.tsx | 65 +++++ apps/web/components/invite-flow.tsx | 236 ++++++++++++++++++ apps/web/components/tenant-nav.tsx | 33 +++ 10 files changed, 459 insertions(+) create mode 100644 apps/web/app/t/[tenantSlug]/(public)/invites/[token]/accept/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(public)/invites/[token]/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(public)/layout.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/layout.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/layout.tsx create mode 100644 apps/web/app/t/[tenantSlug]/tenant-access-gate.tsx create mode 100644 apps/web/app/t/[tenantSlug]/tenant-chrome.tsx create mode 100644 apps/web/components/invite-flow.tsx create mode 100644 apps/web/components/tenant-nav.tsx 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)/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 ( +
+
+
+ {tenantSlug} + + {user?.email ?? "—"} + {user?.role ? ` · ${user.role}` : null} + {user?.isSuperadmin ? " · superadmin" : null} + +
+
+ + All organizations + + +
+
+
+ +
+
{children}
+
+ ); +} 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}. +

+
+ + {error ?

{error}

: null} + +
+
+ ); +} + +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..6edd335 --- /dev/null +++ b/apps/web/components/tenant-nav.tsx @@ -0,0 +1,33 @@ +"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 ( + + ); +} From 5168109127677cc1ee8c466c4d58c891af5cf1be Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 20:47:09 +0100 Subject: [PATCH 7/8] feat: implement organization invitation management UI and API integration --- .../[tenantSlug]/(workspace)/invites/page.tsx | 5 + .../invites/tenant-invites-page.tsx | 248 ++++++++++++++++++ apps/web/components/tenant-nav.tsx | 5 +- .../lib/api/organization-invitations-query.ts | 93 +++++++ apps/web/lib/validation/auth-forms.ts | 4 + 5 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/invites/page.tsx create mode 100644 apps/web/app/t/[tenantSlug]/(workspace)/invites/tenant-invites-page.tsx create mode 100644 apps/web/lib/api/organization-invitations-query.ts 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. +

+ +
+

Invite by email

+
+ + {formError ? ( +

{formError}

+ ) : null} + +
+
+ +
+

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 ? ( +
    + + +
    + ) : null} +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/components/tenant-nav.tsx b/apps/web/components/tenant-nav.tsx index 6edd335..80b7c9d 100644 --- a/apps/web/components/tenant-nav.tsx +++ b/apps/web/components/tenant-nav.tsx @@ -26,7 +26,10 @@ export function TenantNav({ Students (Phase 2) ) : null} {role === "org_admin" || isSuperadmin ? ( - Admin (Phase 4) + Invites + ) : null} + {role === "org_admin" || isSuperadmin ? ( + More admin (Phase 4) ) : null} ); 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/validation/auth-forms.ts b/apps/web/lib/validation/auth-forms.ts index 3c193c3..60c69a6 100644 --- a/apps/web/lib/validation/auth-forms.ts +++ b/apps/web/lib/validation/auth-forms.ts @@ -40,3 +40,7 @@ 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), +}); From 9a10516091da86a1f89a8d1303ac0e4f080dfd33 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Sat, 4 Apr 2026 20:50:40 +0100 Subject: [PATCH 8/8] feat: refactor app shell layout to use a dedicated AppShellHeader component for improved session management and navigation --- apps/web/app/(app)/layout.tsx | 21 +------ apps/web/components/app-shell-header.tsx | 75 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 apps/web/components/app-shell-header.tsx diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 09b5e17..b443933 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,26 +1,11 @@ import type { ReactNode } from "react"; +import { AppShellHeader } from "@/components/app-shell-header"; + export default function AppShellLayout({ children }: { children: ReactNode }) { return (
-
- - Studiqo - - -
+ {children}
); 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 + + +
+ {loading ? ( + Session… + ) : authed ? ( +
+ + {user?.email ?? "—"} + {user?.role ? ` · ${user.role}` : null} + {user?.isSuperadmin ? " · superadmin" : null} + + +
+ ) : null} +
+ ); +}