feat: implement backend auth flow and global auth guard
This commit is contained in:
parent
6f21d62d5f
commit
229c42aa94
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
let accessToken: string | null = null;
|
||||
|
||||
export const authSession = {
|
||||
getToken: () => accessToken,
|
||||
setToken: ({ token }: { token: string }) => {
|
||||
accessToken = token;
|
||||
},
|
||||
clear: () => {
|
||||
accessToken = null;
|
||||
},
|
||||
};
|
||||
|
|
@ -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|.*\\..*).*)",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue