feat: implement backend auth flow and global auth guard

This commit is contained in:
Stanley Cheung 2026-02-09 16:45:23 +08:00
parent 6f21d62d5f
commit 229c42aa94
13 changed files with 866 additions and 52 deletions

View File

@ -7,6 +7,7 @@ NODE_ENV=development
# Public
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
NEXT_PUBLIC_AUTH_API_BASE_URL=http://localhost:3001/api
# Server
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
@ -25,4 +26,4 @@ R2_ACCESS_KEY_ID=your_access_key_here
R2_SECRET_ACCESS_KEY=your_secret_key_here
R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
MODAL_TRANSCRIPTION_URL=your_modal_url_here
MODAL_TRANSCRIPTION_URL=your_modal_url_here

View File

@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuthStore } from "@/stores/auth-store";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") || "/projects";
const login = useAuthStore((state) => state.login);
const clearError = useAuthStore((state) => state.clearError);
const error = useAuthStore((state) => state.error);
const status = useAuthStore((state) => state.status);
const isInitialized = useAuthStore((state) => state.isInitialized);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
useEffect(() => {
if (status === "authenticated") {
router.replace(nextPath);
}
}, [nextPath, router, status]);
const isSubmitting = status === "loading";
return (
<div className="min-h-screen bg-background flex items-center justify-center px-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Sign in</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error ? (
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
) : null}
<form
className="space-y-4"
onSubmit={async (event) => {
event.preventDefault();
clearError();
try {
await login({ username, password });
} catch {
// Error state is already set in the auth store.
}
}}
>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="john"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="********"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Signing in..." : "Sign in"}
</Button>
</form>
<div className="text-sm text-muted-foreground">
No account yet?{" "}
<Link className="text-foreground underline" href="/auth/register">
Create one
</Link>
</div>
{!isInitialized ? (
<div className="text-xs text-muted-foreground">Loading session...</div>
) : null}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,131 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuthStore } from "@/stores/auth-store";
export default function RegisterPage() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") || "/projects";
const register = useAuthStore((state) => state.register);
const clearError = useAuthStore((state) => state.clearError);
const error = useAuthStore((state) => state.error);
const status = useAuthStore((state) => state.status);
const [firstname, setFirstname] = useState("");
const [lastname, setLastname] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [organisationName, setOrganisationName] = useState("");
useEffect(() => {
if (status === "authenticated") {
router.replace(nextPath);
}
}, [nextPath, router, status]);
const isSubmitting = status === "loading";
return (
<div className="min-h-screen bg-background flex items-center justify-center px-4 py-6">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create account</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error ? (
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
) : null}
<form
className="space-y-4"
onSubmit={async (event) => {
event.preventDefault();
clearError();
try {
await register({
email,
firstname,
lastname,
password,
organisationName: organisationName || undefined,
});
} catch {
// Error state is already set in the auth store.
}
}}
>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="firstname">First name</Label>
<Input
id="firstname"
value={firstname}
onChange={(event) => setFirstname(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastname">Last name</Label>
<Input
id="lastname"
value={lastname}
onChange={(event) => setLastname(event.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="organisation_name">Organisation (optional)</Label>
<Input
id="organisation_name"
value={organisationName}
onChange={(event) => setOrganisationName(event.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Creating account..." : "Create account"}
</Button>
</form>
<div className="text-sm text-muted-foreground">
Already have an account?{" "}
<Link className="text-foreground underline" href="/auth/login">
Sign in
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -7,6 +7,8 @@ import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client";
import { webEnv } from "@opencut/env/web";
import { Inter } from "next/font/google";
import { AuthProvider } from "@/components/providers/auth-provider";
import { AuthGuard } from "@/components/auth/auth-guard";
const siteFont = Inter({ subsets: ["latin"] });
@ -35,22 +37,24 @@ export default function RootLayout({
defaultTheme="system"
disableTransitionOnChange={true}
>
<TooltipProvider>
<Toaster />
<Script
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
data-disabled={webEnv.NODE_ENV === "development"}
data-track-attributes={false}
data-track-errors={true}
data-track-outgoing-links={false}
data-track-web-vitals={false}
data-track-sessions={false}
/>
{children}
</TooltipProvider>
<AuthProvider>
<TooltipProvider>
<Toaster />
<Script
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
data-disabled={webEnv.NODE_ENV === "development"}
data-track-attributes={false}
data-track-errors={true}
data-track-outgoing-links={false}
data-track-web-vitals={false}
data-track-sessions={false}
/>
<AuthGuard>{children}</AuthGuard>
</TooltipProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>

View File

@ -46,6 +46,9 @@ import {
} from "@hugeicons/core-free-icons";
import { OcVideoIcon } from "@opencut/ui/icons";
import { Label } from "@/components/ui/label";
import { useAuthStore } from "@/stores/auth-store";
import { backendAuthApi } from "@/lib/auth/api-client";
import { authSession } from "@/lib/auth/session";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@ -124,6 +127,34 @@ export default function ProjectsPage() {
function ProjectsHeader() {
const { viewMode, isHydrated, setViewMode } = useProjectsStore();
const authStatus = useAuthStore((state) => state.status);
const logout = useAuthStore((state) => state.logout);
const [currentWorkspaceName, setCurrentWorkspaceName] = useState("Workspace");
useEffect(() => {
if (authStatus !== "authenticated") {
setCurrentWorkspaceName("Workspace");
return;
}
const token = authSession.getToken();
if (!token) {
return;
}
backendAuthApi
.getWorkspaces({ token })
.then((workspaces) => {
if (workspaces.length > 0) {
setCurrentWorkspaceName(workspaces[0].name);
} else {
setCurrentWorkspaceName("No workspace");
}
})
.catch(() => {
setCurrentWorkspaceName("Workspace");
});
}, [authStatus]);
return (
<header className="sticky top-0 z-20 px-8 bg-background flex flex-col gap-2">
@ -133,8 +164,8 @@ function ProjectsHeader() {
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/" className="text-sm sm:text-base">
Home
<Link href="/workspaces" className="text-sm sm:text-base">
{currentWorkspaceName}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
@ -180,6 +211,22 @@ function ProjectsHeader() {
<div className="flex items-center gap-3 md:gap-4">
<SearchBar className="hidden md:block" />
{authStatus === "authenticated" ? (
<>
<Button
variant="text"
onClick={() => {
logout();
}}
>
Logout
</Button>
</>
) : (
<Link href="/auth/login">
<Button variant="outline">Login</Button>
</Link>
)}
<NewProjectButton />
</div>
</div>

View File

@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { backendAuthApi, type TWorkspace } from "@/lib/auth/api-client";
import { authSession } from "@/lib/auth/session";
function WorkspacesContent() {
const [workspaces, setWorkspaces] = useState<TWorkspace[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const token = authSession.getToken();
if (!token) {
setError("Missing session token");
setIsLoading(false);
return;
}
backendAuthApi
.getWorkspaces({ token })
.then((data) => {
setWorkspaces(data);
})
.catch((requestError) => {
setError(
requestError instanceof Error
? requestError.message
: "Failed to load workspaces",
);
})
.finally(() => {
setIsLoading(false);
});
}, []);
if (isLoading) {
return <div className="text-muted-foreground">Loading workspaces...</div>;
}
if (error) {
return <div className="text-destructive">{error}</div>;
}
return (
<div className="space-y-3">
{workspaces.length === 0 ? (
<div className="text-muted-foreground">No workspaces available.</div>
) : (
workspaces.map((workspace) => (
<Card key={workspace.id}>
<CardHeader>
<CardTitle className="text-base">{workspace.name}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<div>ID: {workspace.id}</div>
<div>Organisation: {workspace.organisation_id}</div>
</CardContent>
</Card>
))
)}
</div>
);
}
export default function WorkspacesPage() {
return (
<div className="min-h-screen bg-background p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Workspaces</h1>
<Link href="/projects">
<Button variant="outline">Back to projects</Button>
</Link>
</div>
<WorkspacesContent />
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/auth-store";
const PUBLIC_PATH_PREFIXES = ["/auth", "/privacy", "/terms"];
export function AuthGuard({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const status = useAuthStore((state) => state.status);
const isInitialized = useAuthStore((state) => state.isInitialized);
const isPublicPath = PUBLIC_PATH_PREFIXES.some((prefix) =>
(pathname ?? "").startsWith(prefix),
);
useEffect(() => {
if (isPublicPath) {
return;
}
if (!isInitialized) {
return;
}
if (status === "authenticated") {
return;
}
const redirectPath = pathname ?? "/projects";
router.replace(`/auth/login?next=${encodeURIComponent(redirectPath)}`);
}, [isInitialized, isPublicPath, pathname, router, status]);
if (isPublicPath) {
return <>{children}</>;
}
if (!isInitialized || status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
Checking session...
</div>
);
}
if (status !== "authenticated") {
return null;
}
return <>{children}</>;
}

View File

@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { useAuthStore } from "@/stores/auth-store";
export function AuthProvider({ children }: { children: ReactNode }) {
const initialize = useAuthStore((state) => state.initialize);
useEffect(() => {
initialize();
}, [initialize]);
return <>{children}</>;
}

View File

@ -0,0 +1,177 @@
export type TBackendAuthError = {
status?: string;
message?: string;
statusCode?: number;
};
export type TBackendUser = {
id: string;
username?: string;
email: string;
firstname?: string;
lastname?: string;
role?: number;
created_at?: string;
updated_at?: string;
};
export type TWorkspace = {
id: string;
organisation_id: string;
name: string;
created_at: string;
updated_at: string;
};
export type TOrganisation = {
id: string;
name: string;
created_at: string;
updated_at: string;
};
type TAuthSuccessPayload<T> = T | { data: T };
export class BackendAuthError extends Error {
public readonly statusCode: number;
constructor({ message, statusCode = 500 }: { message: string; statusCode?: number }) {
super(message);
this.name = "BackendAuthError";
this.statusCode = statusCode;
}
}
const getAuthApiBaseUrl = () => {
const configuredBase = process.env.NEXT_PUBLIC_AUTH_API_BASE_URL;
return (configuredBase && configuredBase.length > 0 ? configuredBase : "/api").replace(
/\/$/,
"",
);
};
const unwrapSuccessPayload = <T,>(payload: TAuthSuccessPayload<T>): T => {
if (payload && typeof payload === "object" && "data" in payload) {
return payload.data;
}
return payload as T;
};
const extractError = ({
payload,
defaultMessage,
defaultStatusCode,
}: {
payload: unknown;
defaultMessage: string;
defaultStatusCode: number;
}) => {
if (payload && typeof payload === "object") {
const errorPayload = payload as TBackendAuthError;
return {
message: errorPayload.message ?? defaultMessage,
statusCode: errorPayload.statusCode ?? defaultStatusCode,
};
}
return { message: defaultMessage, statusCode: defaultStatusCode };
};
async function request<T>({
path,
method = "GET",
body,
token,
}: {
path: string;
method?: "GET" | "POST";
body?: unknown;
token?: string | null;
}): Promise<T> {
const url = `${getAuthApiBaseUrl()}${path}`;
const response = await fetch(url, {
method,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let payload: unknown = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
const { message, statusCode } = extractError({
payload,
defaultMessage: `Request failed with status ${response.status}`,
defaultStatusCode: response.status,
});
throw new BackendAuthError({ message, statusCode });
}
return unwrapSuccessPayload(payload as TAuthSuccessPayload<T>);
}
export const backendAuthApi = {
getUserInit: () => request<boolean>({ path: "/auth/user/init" }),
login: ({
username,
password,
}: {
username: string;
password: string;
}) =>
request<{ token: string; user: TBackendUser }>({
path: "/auth/user/login",
method: "POST",
body: { username, password },
}),
register: ({
email,
firstname,
lastname,
password,
role = 1,
organisation_name,
}: {
email: string;
firstname: string;
lastname: string;
password: string;
role?: number;
organisation_name?: string;
}) =>
request<{ message: string; token: string; user: TBackendUser }>({
path: "/auth/user/register",
method: "POST",
body: { email, firstname, lastname, password, role, organisation_name },
}),
refresh: () =>
request<{ token: string; user: TBackendUser }>({
path: "/auth/refresh",
method: "GET",
}),
logout: () =>
request<{ message?: string }>({
path: "/auth/user/logout",
method: "POST",
}),
getOrganisation: ({ token }: { token: string }) =>
request<TOrganisation>({
path: "/auth/user/organisation",
method: "GET",
token,
}),
getWorkspaces: ({ token }: { token: string }) =>
request<TWorkspace[]>({
path: "/auth/user/workspaces",
method: "GET",
token,
}),
};

View File

@ -0,0 +1,11 @@
let accessToken: string | null = null;
export const authSession = {
getToken: () => accessToken,
setToken: ({ token }: { token: string }) => {
accessToken = token;
},
clear: () => {
accessToken = null;
},
};

View File

@ -1,18 +1,38 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
const PUBLIC_PATH_PREFIXES = ["/auth", "/privacy", "/terms"];
const PUBLIC_EXACT_PATHS = ["/robots.txt", "/sitemap.xml", "/manifest.json"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublicPath =
PUBLIC_EXACT_PATHS.includes(pathname) ||
PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
const hasRefreshCookie = request.cookies.has("refresh_token");
if (isPublicPath) {
return NextResponse.next();
}
if (!hasRefreshCookie) {
const loginUrl = new URL("/auth/login", request.url);
const nextTarget = `${pathname}${request.nextUrl.search}`;
loginUrl.searchParams.set("next", nextTarget);
return NextResponse.redirect(loginUrl);
}
export async function middleware() {
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Match all request paths except:
* - api routes
* - next internal/static assets
* - files with extensions (images, icons, etc from /public)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
"/((?!api|_next/static|_next/image|.*\\..*).*)",
],
};

View File

@ -0,0 +1,132 @@
"use client";
import { create } from "zustand";
import { backendAuthApi, type TBackendUser } from "@/lib/auth/api-client";
import { authSession } from "@/lib/auth/session";
type AuthStatus = "idle" | "loading" | "authenticated" | "unauthenticated";
type AuthState = {
user: TBackendUser | null;
status: AuthStatus;
isInitialized: boolean;
error: string | null;
initialize: () => Promise<void>;
login: ({
username,
password,
}: {
username: string;
password: string;
}) => Promise<void>;
register: ({
email,
firstname,
lastname,
password,
organisationName,
}: {
email: string;
firstname: string;
lastname: string;
password: string;
organisationName?: string;
}) => Promise<void>;
logout: () => Promise<void>;
clearError: () => void;
};
const setAuthenticatedState = ({
token,
user,
set,
}: {
token: string;
user: TBackendUser;
set: (partial: Partial<AuthState>) => void;
}) => {
authSession.setToken({ token });
set({
user,
status: "authenticated",
isInitialized: true,
error: null,
});
};
const clearAuthState = ({ set }: { set: (partial: Partial<AuthState>) => void }) => {
authSession.clear();
set({
user: null,
status: "unauthenticated",
isInitialized: true,
error: null,
});
};
export const useAuthStore = create<AuthState>()((set, get) => ({
user: null,
status: "idle",
isInitialized: false,
error: null,
initialize: async () => {
if (get().isInitialized || get().status === "loading") {
return;
}
set({ status: "loading", error: null });
try {
const response = await backendAuthApi.refresh();
setAuthenticatedState({ token: response.token, user: response.user, set });
} catch {
clearAuthState({ set });
}
},
login: async ({ username, password }) => {
set({ status: "loading", error: null });
try {
const response = await backendAuthApi.login({ username, password });
setAuthenticatedState({ token: response.token, user: response.user, set });
} catch (error) {
const message = error instanceof Error ? error.message : "Login failed";
clearAuthState({ set });
set({ error: message });
throw error;
}
},
register: async ({
email,
firstname,
lastname,
password,
organisationName,
}) => {
set({ status: "loading", error: null });
try {
const response = await backendAuthApi.register({
email,
firstname,
lastname,
password,
organisation_name: organisationName,
});
setAuthenticatedState({ token: response.token, user: response.user, set });
} catch (error) {
const message = error instanceof Error ? error.message : "Registration failed";
clearAuthState({ set });
set({ error: message });
throw error;
}
},
logout: async () => {
set({ status: "loading", error: null });
try {
await backendAuthApi.logout();
} catch {
// Clear local state even if backend logout is unavailable/misconfigured.
} finally {
clearAuthState({ set });
}
},
clearError: () => set({ error: null }),
}));

View File

@ -1,39 +1,85 @@
# Add Auth Pages and Auth Flow
# Auth Pages and Backend Auth Flow
## Goal
Enable optional account login/signup UX so users can authenticate for cloud features.
Integrate frontend auth UX with the external backend auth API as the single auth source. Do not authenticate directly against database from frontend code.
## Decision
- Use backend REST auth endpoints under `/api/auth/*`.
- Do not use direct frontend DB auth paths for production user auth.
- Keep anonymous/local editing usable; enforce auth only for cloud/account features.
## User Stories
- As a user, I want to sign up/sign in with email and password.
- As a returning user, I want session persistence across visits.
- As a product owner, I want routes that require account features to enforce auth.
- As a user, I can register and log in from UI.
- As a returning user, I keep a valid session via refresh flow.
- As a user, I can access organisation/workspace data after login.
- As a product owner, cloud-only pages/actions require auth while local editor remains usable.
## Backend Contract (Current)
- `POST /api/auth/user/login`
- `POST /api/auth/user/register`
- `GET /api/auth/refresh` (refresh token cookie-based)
- `GET /api/auth/user/init`
- `GET /api/auth/user/organisation` (Bearer backend JWT)
- `GET /api/auth/user/workspaces` (Bearer backend JWT)
## Scope
- Add auth screens (Sign In, Sign Up, optional Forgot Password).
- Wire screens to existing Better Auth endpoints (`/api/auth/[...all]`).
- Add protected route handling for account-required pages.
- Add user menu/session indicator in app shell.
### Phase 1 (implement first)
- Add auth API client module for backend routes.
- Add auth state store (token + user + loading/error state).
- Add pages:
- `/auth/login`
- `/auth/register`
- Implement session bootstrap:
- on app load call `/api/auth/refresh`
- if valid, hydrate user/token
- Add basic route guard for cloud/account routes only (not editor core local flow).
### Phase 2
- Add logout flow and token-clear behavior.
- Add auth-aware header/user menu.
- Add organisation/workspace bootstrap after login.
- Add error normalization and UI messaging from backend error shape.
### Phase 3
- Add init check (`/api/auth/user/init`) onboarding path for first admin.
- Add Google login route integration when backend mock is replaced.
- Add E2E coverage for login/refresh/guard behavior.
## Non-Goals
- Replacing local project save flow.
- Direct DB access from frontend for identity/session.
- Full RBAC/permission matrix in this phase.
## Technical Plan
1. Build pages under `apps/web/src/app` (for example `/sign-in`, `/sign-up`).
2. Use `signIn`, `signUp`, `useSession` from `apps/web/src/lib/auth/client.ts`.
3. Add route guard strategy:
- Middleware for protected paths, or
- Server checks at page/layout level.
4. Define post-login redirect target and logout behavior.
5. Add form validation, loading, and error states.
1. Create `lib/auth/api-client.ts` for backend endpoints and typed response mapping.
2. Create `lib/auth/session.ts` (token memory + persistence policy + refresh logic).
3. Build auth pages/forms with validation and backend error mapping.
4. Add guarded route list for account/cloud screens.
5. Add `Authorization: Bearer <backend JWT>` injection for protected API calls.
6. Handle 401 with one refresh attempt, then force sign-in.
## Security Notes
- Keep refresh token in httpOnly cookie (backend-managed).
- Avoid storing refresh token in frontend storage.
- If access token is stored client-side, prefer short-lived in-memory storage with minimal persistence.
## Acceptance Criteria
- New users can create account from UI.
- Existing users can log in and maintain session.
- Protected routes redirect unauthenticated users to sign-in.
- Auth errors display clear, actionable messages.
- Register/login works end-to-end against backend API.
- Refresh flow restores session after reload when cookie is valid.
- Protected cloud/account pages redirect to login when unauthenticated.
- Organisation/workspace endpoints load successfully after auth.
- Local-only editor route remains accessible without login.
## Risks and Mitigations
- Risk: Breaking existing anonymous/local-only editing flow.
- Mitigation: Keep editor usable without auth unless cloud-only actions are triggered.
- Risk: Auth guard accidentally blocks local editing.
- Mitigation: explicit protected-route allowlist; default editor paths remain public.
- Risk: Token mismatch/expiry causes noisy UX.
- Mitigation: centralized fetch wrapper with refresh-once retry policy.
- Risk: Backend error shape differences.
- Mitigation: normalize `{ status, message, statusCode }` at API client boundary.
## Deliverables
- Auth screens and route protection.
- Updated navigation/session UI.
- Basic auth E2E tests.
- Backend-auth-based login/register pages.
- Auth client + session/refresh handling.
- Protected routes for cloud/account features.
- Updated header/session UX and test coverage baseline.