This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3d6b0e0d4e
commit 5386bc51d1
65 changed files with 4058 additions and 161 deletions
+8 -4
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { LanguageSwitcher } from '@/components/language-switcher';
import { LoginForm } from '@/features/auth/login-form';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('common.login');
@@ -9,8 +10,8 @@ export async function generateMetadata(): Promise<Metadata> {
}
/**
* Sits outside the dashboard route group so it renders without the sidebar and
* without the auth requirement.
* Outside the dashboard route group, so it renders without the sidebar and
* without the session requirement.
*/
export default async function LoginPage() {
const t = await getTranslations('common');
@@ -18,13 +19,16 @@ export default async function LoginPage() {
return (
<div className="bg-ink-50 flex min-h-screen items-center justify-center px-6">
<div className="border-ink-200 w-full max-w-sm border bg-white p-8">
<div className="flex items-start justify-between">
<div className="flex items-start justify-between gap-4">
<h1 className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span> {t('appName')}
</h1>
<LanguageSwitcher />
</div>
<p className="text-ink-500 mt-4 text-sm">{t('login.body')}</p>
<p className="text-ink-500 mt-3 text-sm">{t('login.body')}</p>
<LoginForm />
</div>
</div>
);
+16 -18
View File
@@ -1,27 +1,25 @@
import { getTranslations } from 'next-intl/server';
import { LanguageSwitcher } from '@/components/language-switcher';
import { AdminSidebar } from '@/components/layout/admin-sidebar';
import { RequireSession } from '@/features/auth/require-session';
import { SessionBar } from '@/features/auth/session-bar';
/**
* Every route in this group requires an authenticated back-office actor.
* Enforcement is layered: the proxy checks for a session cookie, this layout
* verifies the token server-side, and the API re-checks permissions on every
* request. Only the last one is real security; the first two are UX.
*
* Enforcement is layered, and only the last layer is real security:
* 1. `RequireSession` avoids rendering the shell for a signed-out visitor (UX).
* 2. Permission-aware navigation hides screens they cannot use (UX).
* 3. The API authorises every single request (security).
*/
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const t = await getTranslations('common');
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
<AdminSidebar />
<div className="min-w-0 flex-1">
<header className="border-ink-200 flex h-14 items-center justify-end gap-4 border-b bg-white px-6">
<LanguageSwitcher />
<span className="text-ink-500 text-xs font-medium">{t('signedOut')}</span>
</header>
<main>{children}</main>
<RequireSession>
<div className="flex min-h-screen">
<AdminSidebar />
<div className="min-w-0 flex-1">
<SessionBar />
<main>{children}</main>
</div>
</div>
</div>
</RequireSession>
);
}
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
import { RolesPanel } from '@/features/settings/roles-panel';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.roles');
@@ -12,11 +12,13 @@ export default async function RolesPage() {
const t = await getTranslations('pages.roles');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="role.read"
milestone="M2 — auth & RBAC"
/>
<div className="space-y-6 p-8">
<header>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
</header>
<RolesPanel />
</div>
);
}
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
import { UsersTable } from '@/features/settings/users-table';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.users');
@@ -12,11 +12,13 @@ export default async function UsersPage() {
const t = await getTranslations('pages.users');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="user.read"
milestone="M2 — auth & RBAC"
/>
<div className="space-y-6 p-8">
<header>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
</header>
<UsersTable />
</div>
);
}
+5 -1
View File
@@ -4,6 +4,8 @@ import { getLocale, getTranslations } from 'next-intl/server';
import { LOCALE_TAGS } from '@sport/types';
import { SessionProvider } from '@/features/auth/session-provider';
import '@/styles/globals.css';
export async function generateMetadata(): Promise<Metadata> {
@@ -27,7 +29,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
suppressHydrationWarning
>
<body className="min-h-screen antialiased">
<NextIntlClientProvider>{children}</NextIntlClientProvider>
<NextIntlClientProvider>
<SessionProvider>{children}</SessionProvider>
</NextIntlClientProvider>
</body>
</html>
);
@@ -1,16 +1,36 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { NAVIGATION } from '@/lib/navigation';
/**
* Renders every section for now. Once the session carries permissions, each
* item is filtered with `hasPermission(actor.permissions, item.permission)` —
* the same catalog the API guards read, so menu and enforcement cannot drift.
* Navigation filtered by the signed-in operator's permissions.
*
* This is the visible payoff of RBAC: someone who cannot read orders never sees
* an Orders link, so the admin has no dead ends that 403 on click. It is
* presentation only — the API re-checks every request, because a hidden link is
* one devtools inspection away from being visible.
*
* A client component because the permission set lives in the session. The cost
* is small: the nav is a list of links, and it re-renders only when the session
* changes.
*/
export async function AdminSidebar() {
const t = await getTranslations('common');
const tPages = await getTranslations('pages');
export function AdminSidebar() {
const t = useTranslations('common');
const tPages = useTranslations('pages');
const { can } = useSession();
const pathname = usePathname();
const sections = NAVIGATION.map((section) => ({
...section,
items: section.items.filter((item) => can(item.permission)),
})).filter((section) => section.items.length > 0);
return (
<aside className="border-ink-200 hidden w-60 shrink-0 border-r bg-white lg:block">
@@ -21,22 +41,32 @@ export async function AdminSidebar() {
</div>
<nav className="space-y-6 p-5">
{NAVIGATION.map((section) => (
{sections.map((section) => (
<div key={section.titleKey}>
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{t(`sections.${section.titleKey}`)}
</h2>
<ul className="mt-2 space-y-0.5">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="rounded-card text-ink-600 hover:bg-ink-100 hover:text-ink-950 block px-2 py-1.5 text-sm"
>
{tPages(`${item.labelKey}.title`)}
</Link>
</li>
))}
{section.items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'rounded-card block px-2 py-1.5 text-sm transition-colors',
active
? 'bg-ink-950 text-white'
: 'text-ink-600 hover:bg-ink-100 hover:text-ink-950',
)}
>
{tPages(`${item.labelKey}.title`)}
</Link>
</li>
);
})}
</ul>
</div>
))}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState, type FormEvent } from 'react';
import { isApiClientError } from '@sport/api-client';
import { Button, Input } from '@sport/ui';
import { loginSchema } from '@sport/validation';
import { useSession } from './session-provider';
export function LoginForm() {
const t = useTranslations('common.login');
const router = useRouter();
const { signIn, status } = useSession();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Someone who is already signed in has no business on the login page.
useEffect(() => {
if (status === 'authenticated') {
router.replace('/');
}
}, [status, router]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
const parsed = loginSchema.safeParse({ email, password });
if (!parsed.success) {
setError(t('invalid'));
return;
}
setSubmitting(true);
try {
await signIn(parsed.data.email, parsed.data.password);
router.replace('/');
} catch (caught) {
/**
* The API returns one message for every credential failure — unknown
* email, wrong password, suspended account — so that this form cannot be
* used to enumerate accounts. Surface it verbatim rather than trying to
* be more specific.
*/
if (isApiClientError(caught)) {
setError(caught.status === 429 ? t('throttled') : caught.message);
} else {
setError(t('networkError'));
}
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="mt-6 space-y-4" noValidate>
<div className="space-y-1.5">
<label htmlFor="email" className="text-xs font-semibold uppercase tracking-widest">
{t('email')}
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="username"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
invalid={Boolean(error)}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="text-xs font-semibold uppercase tracking-widest">
{t('password')}
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
invalid={Boolean(error)}
/>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<Button type="submit" fullWidth size="lg" disabled={submitting}>
{submitting ? t('signingIn') : t('signIn')}
</Button>
</form>
);
}
@@ -0,0 +1,63 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Skeleton } from '@sport/ui';
import { useSession } from './session-provider';
/**
* Client-side route guard for the dashboard.
*
* This is UX, **not** security. It removes the flash of an empty admin shell
* for a signed-out visitor and sends them to the login page. Every request the
* shell would make is independently authorised by the API, which is where the
* actual enforcement lives — a determined visitor can render this tree by
* editing memory and will still receive 401s for every byte of data.
*/
export function RequireSession({ children }: { children: React.ReactNode }) {
const { status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === 'anonymous') {
router.replace('/login');
}
}, [status, router]);
if (status === 'loading') {
return (
<div className="space-y-4 p-8" aria-busy="true">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-full max-w-2xl" />
<Skeleton className="h-4 w-full max-w-xl" />
</div>
);
}
if (status === 'anonymous') {
return null;
}
return <>{children}</>;
}
/**
* Renders children only when the session holds the permission.
*
* Again presentation only: it keeps operators from clicking into screens that
* would 403, which is a usability property. The API decides.
*/
export function Can({
permission,
children,
fallback = null,
}: {
permission: Parameters<ReturnType<typeof useSession>['can']>[0];
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const { can } = useSession();
return <>{can(permission) ? children : fallback}</>;
}
@@ -0,0 +1,44 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { Button } from '@sport/ui';
import { LanguageSwitcher } from '@/components/language-switcher';
import { useSession } from './session-provider';
export function SessionBar() {
const t = useTranslations('common');
const { user, signOut } = useSession();
const router = useRouter();
const [isPending, startTransition] = useTransition();
function handleSignOut() {
startTransition(async () => {
await signOut();
router.replace('/login');
});
}
return (
<header className="border-ink-200 flex h-14 items-center justify-end gap-4 border-b bg-white px-6">
<LanguageSwitcher />
{user ? (
<>
<span className="text-ink-500 text-xs font-medium">
{t('session.signedInAs', { name: user.displayName })}
</span>
<Button variant="ghost" size="sm" onClick={handleSignOut} disabled={isPending}>
{t('session.signOut')}
</Button>
</>
) : (
<span className="text-ink-500 text-xs font-medium">{t('signedOut')}</span>
)}
</header>
);
}
@@ -0,0 +1,127 @@
'use client';
import { createContext, use, useCallback, useEffect, useMemo, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import {
hasAllPermissions,
hasAnyPermission,
type CurrentUser,
type Permission,
} from '@sport/types';
import { browserApi, tokenStore } from '@/lib/api';
interface SessionContextValue {
user: CurrentUser | null;
status: 'loading' | 'authenticated' | 'anonymous';
signIn(email: string, password: string): Promise<void>;
signOut(): Promise<void>;
can(permission: Permission): boolean;
canAny(permissions: readonly Permission[]): boolean;
canAll(permissions: readonly Permission[]): boolean;
}
const SessionContext = createContext<SessionContextValue | null>(null);
/**
* Client-side session state.
*
* The access token lives in `tokenStore` (module memory), never in React state
* — it must be readable from inside a fetch callback and must not be persisted.
* On mount this exchanges the httpOnly refresh cookie for a fresh token, which
* is how a page reload recovers a session without anything durable being stored
* where a script could read it.
*/
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(null);
const [status, setStatus] = useState<SessionContextValue['status']>('loading');
const clearSession = useCallback(() => {
tokenStore.set(null);
setUser(null);
setStatus('anonymous');
}, []);
useEffect(() => {
tokenStore.onLost(clearSession);
return () => tokenStore.onLost(null);
}, [clearSession]);
useEffect(() => {
let cancelled = false;
async function restore() {
try {
const refreshed = await browserApi.auth.adminRefresh();
if (cancelled) return;
tokenStore.set(refreshed.accessToken);
const current = await browserApi.auth.me();
if (cancelled) return;
setUser(current);
setStatus('authenticated');
} catch (error) {
if (cancelled) return;
// A missing or expired cookie is the ordinary signed-out case, not a
// fault worth logging.
if (!isApiClientError(error) || error.status !== 401) {
console.warn('Session restore failed', error);
}
clearSession();
}
}
void restore();
return () => {
cancelled = true;
};
}, [clearSession]);
const signIn = useCallback(async (email: string, password: string) => {
const result = await browserApi.auth.adminLogin({ email, password });
tokenStore.set(result.accessToken);
setUser(result.user);
setStatus('authenticated');
}, []);
const signOut = useCallback(async () => {
try {
await browserApi.auth.adminLogout();
} finally {
// Clear local state even if the request failed. The user asked to sign
// out; leaving them apparently signed in is the worse outcome.
clearSession();
}
}, [clearSession]);
const value = useMemo<SessionContextValue>(
() => ({
user,
status,
signIn,
signOut,
// These read the same permission catalog the API guards enforce. This is
// presentation only — hiding a control is not authorization.
can: (permission) => hasAnyPermission(user?.permissions, [permission]),
canAny: (permissions) => hasAnyPermission(user?.permissions, permissions),
canAll: (permissions) => hasAllPermissions(user?.permissions, permissions),
}),
[user, status, signIn, signOut],
);
return <SessionContext value={value}>{children}</SessionContext>;
}
export function useSession(): SessionContextValue {
const context = use(SessionContext);
if (!context) {
throw new Error('useSession must be used inside <SessionProvider>');
}
return context;
}
@@ -0,0 +1,143 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type PermissionGroup, type RoleDetail } from '@sport/types';
import { Badge, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
/**
* Role viewer.
*
* Read-only for now: the grid makes it obvious which role grants what, which is
* the question an operator actually asks. Editing grants is a destructive
* action that wants a confirmation flow and an audit entry, and shipping the
* viewer first means the editor can be designed against something real.
*/
export function RolesPanel() {
const t = useTranslations('settings');
const { can } = useSession();
const [roles, setRoles] = useState<RoleDetail[] | null>(null);
const [groups, setGroups] = useState<PermissionGroup[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
Promise.all([browserApi.admin.listRoles(), browserApi.admin.listPermissions()])
.then(([roleList, permissionGroups]) => {
if (cancelled) return;
setRoles(roleList);
setGroups(permissionGroups);
setSelected(roleList[0]?.id ?? null);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
});
return () => {
cancelled = true;
};
}, [t]);
if (!can(PERMISSIONS.ROLE_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
if (error) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!roles || !groups) {
return (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const active = roles.find((role) => role.id === selected) ?? roles[0];
const granted = new Set<string>(active?.permissions ?? []);
return (
<div className="grid gap-6 lg:grid-cols-[18rem_1fr]">
<nav aria-label={t('roles')} className="space-y-1">
{roles.map((role) => (
<button
key={role.id}
type="button"
onClick={() => setSelected(role.id)}
className={cn(
'rounded-card flex w-full items-baseline justify-between border px-3 py-2 text-left text-sm transition-colors',
role.id === active?.id
? 'border-ink-950 bg-white font-semibold'
: 'hover:bg-ink-100 border-transparent',
)}
>
<span>{role.name}</span>
<span className="text-ink-400 text-xs">{role.userCount}</span>
</button>
))}
</nav>
{active ? (
<section className="border-ink-200 border bg-white p-6">
<header className="flex flex-wrap items-baseline gap-3">
<h2 className="text-lg font-semibold">{active.name}</h2>
<code className="text-ink-500 text-xs">{active.key}</code>
{active.isSystem ? <Badge variant="neutral">{t('systemRole')}</Badge> : null}
<span className="text-ink-500 ml-auto text-xs">
{t('permissionCount', { count: active.permissions.length })}
</span>
</header>
{active.description ? (
<p className="text-ink-500 mt-2 text-sm">{active.description}</p>
) : null}
<div className="mt-6 space-y-5">
{groups.map((group) => (
<div key={group.resource}>
<h3 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{group.resource}
</h3>
<ul className="mt-2 flex flex-wrap gap-1.5">
{group.permissions.map((permission) => {
const has = granted.has(permission.key);
return (
<li key={permission.key}>
<span
className={cn(
'rounded-card inline-flex items-center gap-1.5 border px-2 py-1 text-xs',
has
? 'border-success/40 bg-success/10 text-ink-950'
: 'border-ink-200 text-ink-400',
)}
>
<span aria-hidden>{has ? '✓' : '·'}</span>
{permission.action}
</span>
</li>
);
})}
</ul>
</div>
))}
</div>
</section>
) : null}
</div>
);
}
@@ -0,0 +1,107 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type UserSummary } from '@sport/types';
import { Badge, Skeleton } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
const STATUS_VARIANT = {
ACTIVE: 'success',
INVITED: 'warning',
SUSPENDED: 'neutral',
} as const;
export function UsersTable() {
const t = useTranslations('settings');
const { can } = useSession();
const [users, setUsers] = useState<UserSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
browserApi.admin
.listUsers({ perPage: 50 })
.then((result) => {
if (!cancelled) setUsers([...result.items]);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
});
return () => {
cancelled = true;
};
}, [t]);
// The API would reject this anyway; checking here avoids a guaranteed 403.
if (!can(PERMISSIONS.USER_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
if (error) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!users) {
return (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
return (
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-3xl w-full text-sm">
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr className="text-ink-500 text-[0.625rem] uppercase tracking-widest">
<th className="px-4 py-3 font-semibold">{t('table.name')}</th>
<th className="px-4 py-3 font-semibold">{t('table.email')}</th>
<th className="px-4 py-3 font-semibold">{t('table.type')}</th>
<th className="px-4 py-3 font-semibold">{t('table.roles')}</th>
<th className="px-4 py-3 font-semibold">{t('table.status')}</th>
<th className="px-4 py-3 font-semibold">{t('table.lastLogin')}</th>
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{users.map((user) => (
<tr key={user.id}>
<td className="px-4 py-3 font-medium">{user.displayName}</td>
<td className="text-ink-600 px-4 py-3">{user.email}</td>
<td className="text-ink-600 px-4 py-3">{user.type}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<Badge key={role.id} variant="outline">
{role.name}
</Badge>
))}
</div>
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[user.status]}>{user.status}</Badge>
</td>
<td className="text-ink-500 px-4 py-3">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+73 -4
View File
@@ -1,12 +1,12 @@
import { createApiClient } from '@sport/api-client';
import { clientEnv, getServerEnv } from './env';
import { getServerEnv } from './env';
/**
* The admin's only channel to data.
*
* There is no Prisma client in this application and there never will be. Every
* read and write crosses the REST boundary, which is what guarantees that RBAC,
* read and write crosses the REST boundary, which is what guarantees RBAC,
* validation and audit logging apply uniformly — a second write path is a
* second place for authorization to be forgotten.
*/
@@ -14,7 +14,76 @@ export function getServerApi() {
return createApiClient({ baseUrl: getServerEnv().API_INTERNAL_URL });
}
/**
* In-memory access token.
*
* Module scope rather than React state on purpose: the API client needs to read
* it from inside a `fetch` callback, and it must survive re-renders without
* being persisted. It is never written to localStorage or a readable cookie —
* those outlive the tab and are readable by any script, which is precisely what
* an XSS is looking for. The httpOnly refresh cookie is what survives a reload.
*/
let accessToken: string | null = null;
let onSessionLost: (() => void) | null = null;
/**
* The in-flight refresh, if any.
*
* When an access token expires, every request already in flight gets a 401 at
* roughly the same moment. Without this, each one starts its own rotation — and
* because rotation invalidates the previous token, the second refresh would be
* treated as *token reuse* and revoke the whole family, logging the user out
* for the crime of loading two panels at once. One shared promise, one rotation.
*/
let refreshInFlight: Promise<boolean> | null = null;
export const tokenStore = {
get: (): string | null => accessToken,
set: (token: string | null): void => {
accessToken = token;
},
/** Lets the session provider react when a refresh attempt finally fails. */
onLost: (handler: (() => void) | null): void => {
onSessionLost = handler;
},
};
/**
* Browser client.
*
* `baseUrl: ''` means same-origin: requests go to this app's own host and are
* proxied to the API (Next rewrite in dev, Nginx in production). That is what
* makes the refresh cookie first-party — see the `rewrites()` comment in
* next.config.ts.
*/
export const browserApi = createApiClient({
baseUrl: clientEnv.NEXT_PUBLIC_API_URL,
getAccessToken: () => null, // wired to the auth store in the auth milestone
baseUrl: '',
getAccessToken: () => tokenStore.get(),
/**
* Transparent re-auth: on a 401, rotate once and retry the original request.
* Without this, a 15-minute access token would interrupt an operator
* mid-edit. If the rotation itself fails, the session is genuinely over.
*/
onUnauthorized: () => {
refreshInFlight ??= (async () => {
try {
const refreshed = await browserApi.auth.adminRefresh();
tokenStore.set(refreshed.accessToken);
return true;
} catch {
tokenStore.set(null);
onSessionLost?.();
return false;
} finally {
// Cleared in a microtask so every caller awaiting this rotation sees
// the same result before a new one can start.
queueMicrotask(() => {
refreshInFlight = null;
});
}
})();
return refreshInFlight;
},
});
+28 -1
View File
@@ -13,7 +13,19 @@
},
"login": {
"title": "Sign in",
"body": "Sign-in lands with the auth milestone (M2). Credentials will be exchanged for a short-lived access token plus a rotating, httpOnly refresh cookie scoped to the admin audience."
"body": "Use your back-office account. Sessions are short-lived and refresh automatically.",
"email": "Email",
"password": "Password",
"signIn": "Sign in",
"signingIn": "Signing in…",
"signOut": "Sign out",
"invalid": "Enter a valid email and password.",
"throttled": "Too many attempts. Please wait a few minutes.",
"networkError": "Could not reach the server. Check your connection."
},
"session": {
"signedInAs": "Signed in as {name}",
"signOut": "Sign out"
}
},
"pages": {
@@ -77,5 +89,20 @@
"title": "Roles",
"body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy."
}
},
"settings": {
"roles": "Roles",
"systemRole": "System role",
"permissionCount": "{count} permissions",
"loadFailed": "Could not load this data.",
"noPermission": "You do not have permission to view this.",
"table": {
"name": "Name",
"email": "Email",
"type": "Type",
"roles": "Roles",
"status": "Status",
"lastLogin": "Last sign-in"
}
}
}
+28 -1
View File
@@ -13,7 +13,19 @@
},
"login": {
"title": "Đăng nhập",
"body": "Chức năng đăng nhập sẽ có ở giai đoạn xác thực (M2). Thông tin đăng nhập sẽ được đổi lấy access token ngắn hạn cùng refresh cookie httpOnly xoay vòng, giới hạn cho phạm vi quản trị."
"body": "Dùng tài khoản nội bộ của bạn. Phiên đăng nhập ngắn hạn và tự động làm mới.",
"email": "Email",
"password": "Mật khẩu",
"signIn": "Đăng nhập",
"signingIn": "Đang đăng nhập…",
"signOut": "Đăng xuất",
"invalid": "Nhập email và mật khẩu hợp lệ.",
"throttled": "Quá nhiều lần thử. Vui lòng đợi vài phút.",
"networkError": "Không kết nối được tới máy chủ. Kiểm tra kết nối của bạn."
},
"session": {
"signedInAs": "Đăng nhập với {name}",
"signOut": "Đăng xuất"
}
},
"pages": {
@@ -77,5 +89,20 @@
"title": "Vai trò",
"body": "Trình chỉnh vai trò: mỗi vai trò là một tập quyền có tên, chỉnh được lúc chạy mà không cần triển khai lại."
}
},
"settings": {
"roles": "Vai trò",
"systemRole": "Vai trò hệ thống",
"permissionCount": "{count} quyền",
"loadFailed": "Không tải được dữ liệu.",
"noPermission": "Bạn không có quyền xem mục này.",
"table": {
"name": "Tên",
"email": "Email",
"type": "Loại",
"roles": "Vai trò",
"status": "Trạng thái",
"lastLogin": "Đăng nhập gần nhất"
}
}
}