Stage M2
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user