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
+35 -3
View File
@@ -6,16 +6,48 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig: NextConfig = {
reactStrictMode: true,
/**
* Proxies API calls through this app's own origin.
*
* The refresh token is a `SameSite=Lax` httpOnly cookie, so the browser only
* sends it first-party. Calling the API host directly from the browser would
* mean `SameSite=None; Secure`, which cannot work over plain HTTP in local
* development at all. Production does the same thing at the Nginx layer, so
* dev and prod share one topology instead of two.
*/
async rewrites() {
const target = process.env.API_INTERNAL_URL ?? 'http://localhost:4000';
return [{ source: '/api/:path*', destination: `${target}/api/:path*` }];
},
transpilePackages: ['@sport/ui'],
typedRoutes: true,
output: 'standalone',
images: {
/**
* Media is served from R2/CDN in production and MinIO locally.
*
* `pathname` and `search` are specified explicitly: Next 16 matches remote
* patterns strictly, and an entry without them does not authorise the URL —
* the optimizer answers `"url" parameter is not allowed` and every product
* image renders broken. Scoping to the bucket path also keeps this from
* becoming an open image proxy.
*/
remotePatterns: [
{ protocol: 'http', hostname: 'localhost', port: '9000' },
{ protocol: 'https', hostname: '**.r2.dev' },
{ protocol: 'https', hostname: 'cdn.sport-store.local' },
{ protocol: 'http', hostname: 'localhost', port: '9000', pathname: '/**', search: '' },
{ protocol: 'https', hostname: '**.r2.dev', pathname: '/**', search: '' },
{ protocol: 'https', hostname: 'cdn.sport-store.local', pathname: '/**', search: '' },
],
/**
* DEVELOPMENT ONLY. See the storefront config for the full explanation:
* Next 16 blocks upstream images on private IPs (SSRF guard) and reports it
* with the same message as an unmatched pattern. Local MinIO is on
* localhost, production media is on a public CDN host.
*/
dangerouslyAllowLocalIP: process.env.NODE_ENV !== 'production',
},
// The admin is an internal tool: keep it out of every index, permanently.
+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"
}
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ STORAGE_PUBLIC_URL=http://localhost:9000/sport-media
# --- Rate limiting ----------------------------------------------------------
RATE_LIMIT_TTL_SECONDS=60
RATE_LIMIT_MAX=120
RATE_LIMIT_MAX=300
# --- Observability ----------------------------------------------------------
LOG_LEVEL=debug
+4 -1
View File
@@ -17,7 +17,8 @@
"db:deploy": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset --force"
"db:reset": "prisma migrate reset --force",
"create-admin": "tsx prisma/create-admin.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
@@ -37,6 +38,7 @@
"@sport/types": "workspace:*",
"@sport/validation": "workspace:*",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"nestjs-pino": "^4.6.1",
@@ -54,6 +56,7 @@
"@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/node": "^22.19.0",
+83
View File
@@ -0,0 +1,83 @@
/**
* Creates or repairs a SUPER_ADMIN account.
*
* pnpm --filter @sport/api run create-admin
* ADMIN_EMAIL=me@example.com ADMIN_PASSWORD='…' pnpm ... run create-admin
*
* This is the production bootstrap path, and the reason the seed never creates
* a privileged account with a known password. Safe to re-run: an existing
* account has its password reset and its role re-granted, which doubles as the
* "locked out of the admin" recovery procedure.
*/
import { PrismaClient } from '@prisma/client';
import { SYSTEM_ROLES } from '@sport/types';
import { generatePassword, hashPassword, verifyPassword } from './seed/accounts';
const prisma = new PrismaClient();
async function main(): Promise<void> {
const email = (process.env['ADMIN_EMAIL'] ?? 'admin@sport.local').trim().toLowerCase();
const provided = process.env['ADMIN_PASSWORD'];
const password = provided ?? generatePassword();
if (provided && provided.length < 10) {
throw new Error('ADMIN_PASSWORD must be at least 10 characters.');
}
const passwordHash = await hashPassword(password);
// Verify the hash round-trips before writing it. A malformed hash here would
// create an account nobody can ever sign in to, and the failure would only
// surface at the login screen.
if (!(await verifyPassword(password, passwordHash))) {
throw new Error('Password hash failed self-verification; refusing to write.');
}
const role = await prisma.role.findUnique({ where: { key: SYSTEM_ROLES.SUPER_ADMIN } });
if (!role) {
throw new Error('The super_admin role is missing. Run `pnpm db:seed` first.');
}
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash, status: 'ACTIVE', type: 'SUPER_ADMIN', deletedAt: null },
create: {
email,
passwordHash,
type: 'SUPER_ADMIN',
status: 'ACTIVE',
firstName: 'Super',
lastName: 'Admin',
emailVerifiedAt: new Date(),
},
select: { id: true },
});
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.id, roleId: role.id } },
update: {},
create: { userId: user.id, roleId: role.id },
});
console.log('\nSuper admin ready.\n');
console.log(` Email: ${email}`);
if (provided) {
console.log(' Password: (from ADMIN_PASSWORD)');
} else {
console.log(` Password: ${password}`);
console.log('\n Generated password — shown once. Store it now.');
}
console.log('');
}
main()
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
})
.finally(() => {
void prisma.$disconnect();
});
+14
View File
@@ -9,6 +9,7 @@
import { PrismaClient } from '@prisma/client';
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
import { seedDevAccounts } from './seed/accounts';
import { seedCatalog } from './seed/catalog';
const prisma = new PrismaClient();
@@ -144,6 +145,19 @@ async function main(): Promise<void> {
}
await seedCatalog(prisma);
const accounts = await seedDevAccounts(prisma);
const created = accounts.filter((account) => account.created);
if (created.length > 0) {
console.log('\nDevelopment sign-in accounts (shown once):\n');
for (const account of created) {
console.log(` ${account.email.padEnd(24)} ${account.password} [${account.role}]`);
}
console.log('\n Development only. Use `pnpm db:create-admin` for real environments.\n');
} else {
console.log('Development accounts already exist; passwords left unchanged.');
}
}
main()
+167
View File
@@ -0,0 +1,167 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import type { PrismaClient } from '@prisma/client';
import { SYSTEM_ROLES } from '@sport/types';
/**
* Password hashing for scripts.
*
* Deliberately duplicated from `common/security/password.service.ts` rather
* than imported: these scripts run under `tsx` outside the Nest container, and
* booting the DI graph to hash one string would be the more fragile choice.
*
* The hash FORMAT is the contract between the two, and it is self-describing —
* so a drift shows up as a failed login on the very next attempt, not as silent
* corruption. If a third caller ever appears, extract it to a package.
*/
const PARAMS = { N: 16_384, r: 8, p: 1 } as const;
const KEY_LENGTH = 64;
const MAX_MEM = 64 * 1024 * 1024;
export function hashPassword(plaintext: string): Promise<string> {
const salt = randomBytes(16);
return new Promise((resolve, reject) => {
scrypt(
plaintext.normalize('NFKC'),
salt,
KEY_LENGTH,
{ ...PARAMS, maxmem: MAX_MEM },
(error, derived) => {
if (error) return reject(error);
resolve(
[
'scrypt',
PARAMS.N,
PARAMS.r,
PARAMS.p,
salt.toString('base64'),
derived.toString('base64'),
].join('$'),
);
},
);
});
}
/** Used by the smoke test below to prove the format round-trips. */
export function verifyPassword(plaintext: string, stored: string): Promise<boolean> {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return Promise.resolve(false);
const [, n, r, p, salt, hash] = parts;
return new Promise((resolve) => {
scrypt(
plaintext.normalize('NFKC'),
Buffer.from(salt ?? '', 'base64'),
KEY_LENGTH,
{ N: Number(n), r: Number(r), p: Number(p), maxmem: MAX_MEM },
(error, derived) => {
if (error) return resolve(false);
const expected = Buffer.from(hash ?? '', 'base64');
resolve(derived.length === expected.length && timingSafeEqual(derived, expected));
},
);
});
}
/** A readable, high-entropy password for generated accounts. */
export function generatePassword(): string {
// Base64url of 18 bytes ≈ 24 characters, ~144 bits. Suffixed to guarantee the
// policy's uppercase/lowercase/digit requirements regardless of the draw.
return `${randomBytes(18).toString('base64url')}aA1`;
}
export interface SeededAccount {
email: string;
password: string;
role: string;
created: boolean;
}
/**
* Development sign-in accounts.
*
* Guarded twice — by NODE_ENV and by an explicit opt-out — because a known
* password reaching production is the single worst thing a seed can do. The
* generated password is printed once and never stored anywhere else.
*
* Existing accounts are left completely alone: re-running the seed must not
* reset a password someone has already changed.
*/
export async function seedDevAccounts(prisma: PrismaClient): Promise<SeededAccount[]> {
const accounts: SeededAccount[] = [];
const definitions = [
{
email: 'admin@sport.local',
type: 'SUPER_ADMIN' as const,
firstName: 'Demo',
lastName: 'Admin',
roleKey: SYSTEM_ROLES.SUPER_ADMIN,
},
{
email: 'staff@sport.local',
type: 'STAFF' as const,
firstName: 'Demo',
lastName: 'Staff',
roleKey: SYSTEM_ROLES.CATALOG_MANAGER,
},
{
email: 'customer@sport.local',
type: 'CUSTOMER' as const,
firstName: 'Demo',
lastName: 'Customer',
roleKey: SYSTEM_ROLES.CUSTOMER,
},
];
for (const definition of definitions) {
const existing = await prisma.user.findUnique({ where: { email: definition.email } });
if (existing) {
accounts.push({
email: definition.email,
password: '(unchanged)',
role: definition.roleKey,
created: false,
});
continue;
}
const password = generatePassword();
const role = await prisma.role.findUnique({ where: { key: definition.roleKey } });
const user = await prisma.user.create({
data: {
email: definition.email,
passwordHash: await hashPassword(password),
type: definition.type,
status: 'ACTIVE',
firstName: definition.firstName,
lastName: definition.lastName,
emailVerifiedAt: new Date(),
...(role ? { roles: { create: { roleId: role.id } } } : {}),
},
select: { id: true },
});
// A customer account needs its shopper profile, or /account has nothing to
// hang addresses and orders off later.
if (definition.type === 'CUSTOMER') {
await prisma.customer.create({ data: { userId: user.id } });
}
accounts.push({
email: definition.email,
password,
role: definition.roleKey,
created: true,
});
}
return accounts;
}
+2
View File
@@ -5,6 +5,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
import { MediaUrlModule } from './common/media/media.module';
import { SecurityModule } from './common/security/security.module';
import { APP_CONFIG, AppConfigModule } from './config/app-config.module';
import type { AppConfig } from './config/configuration';
import { EventsModule } from './infrastructure/events/events.module';
@@ -55,6 +56,7 @@ import { WishlistModule } from './modules/wishlist/wishlist.module';
StorageModule,
EventsModule,
MediaUrlModule,
SecurityModule,
ThrottlerModule.forRootAsync({
inject: [APP_CONFIG],
@@ -0,0 +1,154 @@
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
/**
* Hand-written rather than `promisify(scrypt)`: promisify resolves to the
* no-options overload, so passing `maxmem` becomes a type error even though the
* runtime accepts it.
*/
function scryptAsync(
password: string,
salt: Buffer,
keylen: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, options, (error, derivedKey) => {
if (error) reject(error);
else resolve(derivedKey);
});
});
}
/**
* Password hashing.
*
* ALGORITHM CHOICE
* scrypt from `node:crypto`, at OWASP's recommended parameters. Argon2id would
* be the first choice on paper, but every Node binding for it is a native
* module — a compile step in CI, a platform matrix in Docker, and a class of
* deployment failure that has nothing to do with this application. scrypt is
* memory-hard, standardised (RFC 7914), on OWASP's approved list, and already
* in the runtime.
*
* ALGORITHM AGILITY
* Hashes are stored self-describing:
*
* scrypt$16384$8$1$<salt-b64>$<hash-b64>
*
* The verifier reads its parameters from the stored string rather than from
* today's constants, so raising the cost — or moving to Argon2id later — is a
* transparent rehash-on-next-login. `needsRehash()` reports when that applies.
* A password store you cannot upgrade is a password store you are stuck with.
*/
/**
* N=2^14 (16 MiB), r=8, p=1 — OWASP's minimum for scrypt.
*
* Deliberately not higher: this runs on the login path, and a cost that makes
* sign-in feel slow is a cost someone will quietly lower in six months. The
* `maxmem` bump is required because Node's default ceiling is below what N
* needs.
*/
const CURRENT_PARAMS = { N: 16_384, r: 8, p: 1 } as const;
const KEY_LENGTH = 64;
const SALT_LENGTH = 16;
const MAX_MEM = 64 * 1024 * 1024;
@Injectable()
export class PasswordService {
private readonly logger = new Logger(PasswordService.name);
async hash(plaintext: string): Promise<string> {
const salt = randomBytes(SALT_LENGTH);
const derived = await this.derive(plaintext, salt, CURRENT_PARAMS);
return [
'scrypt',
CURRENT_PARAMS.N,
CURRENT_PARAMS.r,
CURRENT_PARAMS.p,
salt.toString('base64'),
derived.toString('base64'),
].join('$');
}
/**
* Constant-time verification.
*
* Returns false on a malformed hash rather than throwing: a corrupt row must
* read as "wrong password", not as a 500 that tells an attacker the account
* exists and is in an unusual state.
*/
async verify(plaintext: string, stored: string | null | undefined): Promise<boolean> {
if (!stored) return false;
const parsed = this.parse(stored);
if (!parsed) {
this.logger.warn('Encountered an unparseable password hash');
return false;
}
try {
const derived = await this.derive(plaintext, parsed.salt, parsed.params);
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
} catch (error) {
this.logger.error(`Password verification failed: ${String(error)}`);
return false;
}
}
/** True when the stored hash uses weaker parameters than we now require. */
needsRehash(stored: string): boolean {
const parsed = this.parse(stored);
if (!parsed) return true;
return (
parsed.params.N < CURRENT_PARAMS.N ||
parsed.params.r < CURRENT_PARAMS.r ||
parsed.params.p < CURRENT_PARAMS.p
);
}
/**
* A hash of a throwaway value, used to keep login timing flat when the email
* does not exist. Without it, "unknown email" returns measurably faster than
* "wrong password", which turns the login form into a user enumeration oracle.
*/
async burnCycles(): Promise<void> {
await this.derive('timing-equalisation', Buffer.alloc(SALT_LENGTH), CURRENT_PARAMS);
}
private derive(
plaintext: string,
salt: Buffer,
params: { N: number; r: number; p: number },
): Promise<Buffer> {
return scryptAsync(plaintext.normalize('NFKC'), salt, KEY_LENGTH, {
...params,
maxmem: MAX_MEM,
});
}
private parse(
stored: string,
): { params: { N: number; r: number; p: number }; salt: Buffer; hash: Buffer } | null {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return null;
const [, rawN, rawR, rawP, rawSalt, rawHash] = parts;
const N = Number(rawN);
const r = Number(rawR);
const p = Number(rawP);
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return null;
if (!rawSalt || !rawHash) return null;
return {
params: { N, r, p },
salt: Buffer.from(rawSalt, 'base64'),
hash: Buffer.from(rawHash, 'base64'),
};
}
}
@@ -0,0 +1,66 @@
import { PasswordService } from './password.service';
/**
* Password handling has no second chance: a bug here is either "nobody can log
* in" or "everybody can". These tests pin the properties that matter rather
* than the implementation.
*/
describe('PasswordService', () => {
const service = new PasswordService();
it('round-trips a password', async () => {
const hash = await service.hash('Correct horse battery 1');
await expect(service.verify('Correct horse battery 1', hash)).resolves.toBe(true);
});
it('rejects the wrong password', async () => {
const hash = await service.hash('Correct horse battery 1');
await expect(service.verify('Correct horse battery 2', hash)).resolves.toBe(false);
});
it('salts, so the same password hashes differently every time', async () => {
const [a, b] = await Promise.all([service.hash('SameInput123'), service.hash('SameInput123')]);
expect(a).not.toBe(b);
await expect(service.verify('SameInput123', a)).resolves.toBe(true);
await expect(service.verify('SameInput123', b)).resolves.toBe(true);
});
it('emits a self-describing hash so parameters can change later', async () => {
const hash = await service.hash('Parameters123');
const [algorithm, n, r, p] = hash.split('$');
expect(algorithm).toBe('scrypt');
expect(Number(n)).toBeGreaterThanOrEqual(16_384);
expect(Number(r)).toBeGreaterThanOrEqual(8);
expect(Number(p)).toBeGreaterThanOrEqual(1);
});
it('treats a null or malformed hash as a failed verification, never an error', async () => {
// A corrupt row must read as "wrong password". Throwing would tell an
// attacker the account exists and is in an unusual state.
await expect(service.verify('anything', null)).resolves.toBe(false);
await expect(service.verify('anything', undefined)).resolves.toBe(false);
await expect(service.verify('anything', 'not-a-hash')).resolves.toBe(false);
await expect(service.verify('anything', 'scrypt$bad$params$here$x$y')).resolves.toBe(false);
});
it('normalises unicode, so an accented password survives a different keyboard', async () => {
// U+00E9 vs U+0065 U+0301 — visually identical, different bytes.
const composed = 'caféPassw0rd';
const decomposed = 'caféPassw0rd';
const hash = await service.hash(composed);
await expect(service.verify(decomposed, hash)).resolves.toBe(true);
});
it('flags weaker stored parameters for rehash', () => {
expect(service.needsRehash('scrypt$1024$8$1$c2FsdA==$aGFzaA==')).toBe(true);
expect(service.needsRehash('garbage')).toBe(true);
});
it('does not flag a current hash for rehash', async () => {
const hash = await service.hash('CurrentParams1');
expect(service.needsRehash(hash)).toBe(false);
});
});
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { PasswordService } from './password.service';
/**
* Stateless security primitives.
*
* `PasswordService` lives here rather than in AuthModule to break a dependency
* cycle: AuthModule needs UsersModule to look up accounts, and UsersModule
* needs password hashing to create them. Hashing has no dependencies of its
* own, so hoisting it out of both is the fix — `forwardRef` would only hide the
* cycle rather than remove it.
*/
@Global()
@Module({
providers: [PasswordService],
exports: [PasswordService],
})
export class SecurityModule {}
+11 -1
View File
@@ -45,7 +45,17 @@ export const envSchema = z.object({
STORAGE_PUBLIC_URL: z.url(),
RATE_LIMIT_TTL_SECONDS: z.coerce.number().int().positive().default(60),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
/**
* Coarse per-IP ceiling — a DoS safety net, not the real protection.
*
* Raised from 120: many users share one address behind corporate NAT or
* carrier-grade NAT, and a catalog page makes several API calls. At 120/min a
* single office could exhaust the budget and start receiving 429s on
* `/auth/refresh`, which the client correctly reads as "session over" and
* signs everyone out. Fine-grained protection lives per-endpoint — see
* LoginThrottleService, which counts failures per account and per IP.
*/
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
LOG_PRETTY: z.stringbool().default(false),
+4
View File
@@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { Logger } from 'nestjs-pino';
@@ -31,6 +32,9 @@ async function bootstrap(): Promise<void> {
// First in the chain: every log line and error response carries this id.
app.use(requestIdMiddleware);
// Refresh tokens arrive as httpOnly cookies; without this `req.cookies` is
// undefined and every refresh silently fails as "no session".
app.use(cookieParser());
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
app.use(compression());
@@ -0,0 +1,194 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Post,
Req,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import {
TOKEN_AUDIENCES,
type AuthenticatedActor,
type CurrentUser,
type LoginResult,
type RefreshResult,
type SessionSummary,
type TokenAudience,
} from '@sport/types';
import { loginSchema, type LoginInput } from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import { Public } from '@/common/decorators/public.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
import { AuthService, type IssuedSession, type RequestContext } from './auth.service';
import { clearRefreshCookie, readRefreshCookie, setRefreshCookie } from './refresh-cookie';
/**
* Credential exchange.
*
* Storefront and admin have separate login endpoints rather than one endpoint
* that infers the audience. The audience decides which population may sign in
* and which cookie is issued — inferring it from the account would mean a
* single leaked customer credential could be pointed at the admin surface and
* only a later check would stop it. Two routes make the boundary explicit.
*/
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Customer sign-in (storefront audience)' })
login(
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<LoginResult> {
return this.handleLogin(body, TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Back-office sign-in (admin audience)' })
adminLogin(
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<LoginResult> {
return this.handleLogin(body, TOKEN_AUDIENCES.ADMIN, request, response);
}
/**
* Public because it authenticates with the cookie, not with an access token —
* the whole point is to be callable once the access token has expired.
*/
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Rotate the storefront refresh token' })
refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<RefreshResult> {
return this.handleRefresh(TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Rotate the admin refresh token' })
adminRefresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<RefreshResult> {
return this.handleRefresh(TOKEN_AUDIENCES.ADMIN, request, response);
}
@Public()
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Sign out of the storefront' })
logout(@Req() request: Request, @Res({ passthrough: true }) response: Response): Promise<void> {
return this.handleLogout(TOKEN_AUDIENCES.STOREFRONT, request, response);
}
@Public()
@Post('admin/logout')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Sign out of the admin' })
adminLogout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
return this.handleLogout(TOKEN_AUDIENCES.ADMIN, request, response);
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'The signed-in user, with roles and permissions' })
me(@CurrentActor() actor: AuthenticatedActor): Promise<CurrentUser> {
return this.authService.me(actor.userId);
}
@Get('sessions')
@ApiBearerAuth()
@ApiOperation({ summary: 'Active sessions for the signed-in user' })
sessions(@CurrentActor() actor: AuthenticatedActor): Promise<SessionSummary[]> {
return this.authService.listSessions(actor.userId, actor.sessionId);
}
// ---- shared handlers -----------------------------------------------------
private async handleLogin(
body: LoginInput,
audience: TokenAudience,
request: Request,
response: Response,
): Promise<LoginResult> {
const { result, session } = await this.authService.login(body, audience, contextOf(request));
this.writeSession(response, audience, session);
return result;
}
private async handleRefresh(
audience: TokenAudience,
request: Request,
response: Response,
): Promise<RefreshResult> {
const { result, session } = await this.authService.refresh(
readRefreshCookie(request, audience),
audience,
contextOf(request),
);
this.writeSession(response, audience, session);
return result;
}
private async handleLogout(
audience: TokenAudience,
request: Request,
response: Response,
): Promise<void> {
await this.authService.logout(readRefreshCookie(request, audience));
clearRefreshCookie(response, audience, this.config.app.isProduction);
}
private writeSession(response: Response, audience: TokenAudience, session: IssuedSession): void {
setRefreshCookie(
response,
audience,
session.refreshToken,
session.refreshTokenExpiresAt,
this.config.app.isProduction,
);
}
}
/**
* The client IP comes from Express's `trust proxy` handling, which is why
* `app.set('trust proxy', 1)` in main.ts matters — without it every request
* behind Nginx would look like it came from the proxy, and the per-IP login
* throttle would lock out the entire internet at once.
*/
function contextOf(request: Request): RequestContext {
return {
userAgent: request.header('user-agent')?.slice(0, 512) ?? null,
ipAddress: request.ip ?? null,
};
}
+18 -7
View File
@@ -2,27 +2,38 @@ import { Global, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '@/modules/users/users.module';
import { AuthController } from './auth.controller';
import { AuthRepository } from './auth.repository';
import { AuthService } from './auth.service';
import { AccessTokenGuard } from './guards/access-token.guard';
import { PermissionsGuard } from './guards/permissions.guard';
import { LoginThrottleService } from './login-throttle.service';
import { TokenService } from './token.service';
/**
* Milestone 0 provides the *enforcement* half of auth: token verification,
* audience separation and RBAC evaluation, wired globally.
* AuthModule — owns `sessions`, and nothing else.
*
* The *issuance* half — login, registration, refresh rotation, password reset,
* OTP — is milestone 1. Splitting it this way means every endpoint written from
* here on is protected by default, before a single credential exists.
* Accounts, roles and permissions belong to UsersModule; this module exchanges
* credentials for tokens and manages session lifetime. Password *hashing* lives
* in `common/security` so that both modules can use it without a cycle.
*
* Guard order matters: AccessTokenGuard must populate `request.actor` before
* PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order.
*/
@Global()
@Module({
imports: [JwtModule.register({})],
imports: [JwtModule.register({}), UsersModule],
controllers: [AuthController],
providers: [
AuthService,
AuthRepository,
TokenService,
LoginThrottleService,
{ provide: APP_GUARD, useClass: AccessTokenGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
exports: [JwtModule],
exports: [JwtModule, AuthService],
})
export class AuthModule {}
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
/**
* The session table — the only data AuthModule owns.
*
* One row per refresh token. Rotation appends a new row and links the old one
* to it, so a session's full history is reconstructable, which is what makes
* token-reuse detection possible at all.
*/
@Injectable()
export class AuthRepository {
constructor(private readonly prisma: PrismaService) {}
findByRefreshHash(refreshTokenHash: string) {
return this.prisma.session.findUnique({
where: { refreshTokenHash },
select: {
id: true,
userId: true,
familyId: true,
replacedById: true,
revokedAt: true,
expiresAt: true,
},
});
}
create(params: {
userId: string;
familyId: string;
refreshTokenHash: string;
expiresAt: Date;
userAgent: string | null;
ipAddress: string | null;
}) {
return this.prisma.session.create({
data: {
userId: params.userId,
familyId: params.familyId,
refreshTokenHash: params.refreshTokenHash,
expiresAt: params.expiresAt,
userAgent: params.userAgent,
ipAddress: params.ipAddress,
},
select: { id: true, familyId: true },
});
}
/**
* Rotates a session: creates the successor and links the predecessor to it,
* in one transaction.
*
* If this were two statements and the second failed, the old token would stay
* valid alongside the new one — two live credentials from one refresh, which
* defeats the point of rotating.
*/
async rotate(params: {
previousSessionId: string;
userId: string;
familyId: string;
refreshTokenHash: string;
expiresAt: Date;
userAgent: string | null;
ipAddress: string | null;
}) {
return this.prisma.$transaction(async (tx) => {
const next = await tx.session.create({
data: {
userId: params.userId,
familyId: params.familyId,
refreshTokenHash: params.refreshTokenHash,
expiresAt: params.expiresAt,
userAgent: params.userAgent,
ipAddress: params.ipAddress,
},
select: { id: true },
});
await tx.session.update({
where: { id: params.previousSessionId },
data: { replacedById: next.id },
});
return next;
});
}
/** Revokes one session — a single device signing out. */
async revoke(sessionId: string): Promise<void> {
await this.prisma.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Revokes an entire token family.
*
* Called when a refresh token is replayed. Either the token leaked or the
* client is broken; both warrant forcing a fresh sign-in on that device.
*/
async revokeFamily(familyId: string): Promise<number> {
const result = await this.prisma.session.updateMany({
where: { familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
}
/** Signs a user out everywhere — used after a password change. */
async revokeAllForUser(userId: string): Promise<number> {
const result = await this.prisma.session.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
}
listActiveForUser(userId: string) {
return this.prisma.session.findMany({
where: { userId, revokedAt: null, replacedById: null, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ipAddress: true,
createdAt: true,
expiresAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Housekeeping for expired rows.
*
* Rotation is append-only, so this table grows with every refresh — a daily
* job calls this. Rows are kept a week past expiry so a security review can
* still see what happened.
*/
async deleteExpired(before = new Date(Date.now() - 7 * 86_400_000)): Promise<number> {
const result = await this.prisma.session.deleteMany({ where: { expiresAt: { lt: before } } });
return result.count;
}
}
+298
View File
@@ -0,0 +1,298 @@
import { Injectable, Logger } from '@nestjs/common';
import {
API_ERROR_CODES,
TOKEN_AUDIENCES,
isBackOfficeUser,
type CurrentUser,
type LoginResult,
type RefreshResult,
type SessionSummary,
type TokenAudience,
type UserType,
} from '@sport/types';
import type { LoginInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { PasswordService } from '@/common/security/password.service';
import { UsersService, type AuthUserRow } from '@/modules/users/public';
import { AuthRepository } from './auth.repository';
import { LoginThrottleService } from './login-throttle.service';
import { TokenService } from './token.service';
export interface RequestContext {
userAgent: string | null;
ipAddress: string | null;
}
export interface IssuedSession {
accessToken: string;
accessTokenExpiresAt: Date;
refreshToken: string;
refreshTokenExpiresAt: Date;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly usersService: UsersService,
private readonly passwordService: PasswordService,
private readonly tokenService: TokenService,
private readonly repository: AuthRepository,
private readonly throttle: LoginThrottleService,
) {}
/**
* Exchanges credentials for a session.
*
* Every failure path returns the same message and the same status. Telling a
* caller apart — "no such account" vs "wrong password" vs "suspended" — turns
* the login form into a user-enumeration oracle, and the timing is equalised
* for the same reason.
*/
async login(
input: LoginInput,
audience: TokenAudience,
context: RequestContext,
): Promise<{ result: LoginResult; session: IssuedSession }> {
await this.throttle.assertNotLocked(input.email, context.ipAddress);
const user = await this.usersService.findForAuthByEmail(input.email);
if (!user) {
// Spend the same CPU as a real verification so a missing account is not
// measurably faster than a wrong password.
await this.passwordService.burnCycles();
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
const passwordValid = await this.passwordService.verify(input.password, user.passwordHash);
if (!passwordValid) {
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
if (user.status !== 'ACTIVE') {
await this.throttle.recordFailure(input.email, context.ipAddress);
throw invalidCredentials();
}
this.assertAudience(user.type as UserType, audience);
// Transparent upgrade if the stored hash predates the current cost.
if (user.passwordHash && this.passwordService.needsRehash(user.passwordHash)) {
await this.usersService.updatePasswordHash(
user.id,
await this.passwordService.hash(input.password),
);
this.logger.log(`Upgraded password hash parameters for user ${user.id}`);
}
const session = await this.startSession(user, audience, context);
await Promise.all([
this.usersService.recordLogin(user.id),
this.throttle.recordSuccess(input.email),
]);
return {
result: {
user: this.usersService.toCurrentUser(user),
accessToken: session.accessToken,
accessTokenExpiresAt: session.accessTokenExpiresAt.toISOString(),
},
session,
};
}
/**
* Rotates a refresh token.
*
* The security-critical branch is reuse detection: a token that has already
* been rotated or revoked must never work again, and being presented with one
* means either it leaked or the client is broken. Both justify killing the
* whole family — that is the difference between detecting theft and merely
* limiting its window.
*/
async refresh(
refreshToken: string | undefined,
audience: TokenAudience,
context: RequestContext,
): Promise<{ result: RefreshResult; session: IssuedSession }> {
if (!refreshToken) {
throw AppException.unauthenticated('Your session has expired. Please sign in again.');
}
const hash = this.tokenService.hashRefreshToken(refreshToken);
const existing = await this.repository.findByRefreshHash(hash);
if (!existing) {
throw AppException.unauthenticated(
'Your session has expired. Please sign in again.',
API_ERROR_CODES.TOKEN_INVALID,
);
}
if (existing.revokedAt !== null || existing.replacedById !== null) {
const revoked = await this.repository.revokeFamily(existing.familyId);
this.logger.error(
`Refresh token reuse detected for user ${existing.userId}; revoked ${revoked} session(s) in family ${existing.familyId}`,
);
throw AppException.unauthenticated(
'Your session is no longer valid. Please sign in again.',
API_ERROR_CODES.TOKEN_INVALID,
);
}
if (existing.expiresAt.getTime() <= Date.now()) {
throw AppException.unauthenticated(
'Your session has expired. Please sign in again.',
API_ERROR_CODES.TOKEN_EXPIRED,
);
}
const user = await this.usersService.findForAuthById(existing.userId);
if (!user || user.status !== 'ACTIVE') {
await this.repository.revokeFamily(existing.familyId);
throw AppException.unauthenticated('Your session is no longer valid. Please sign in again.');
}
this.assertAudience(user.type as UserType, audience);
const refresh = this.tokenService.issueRefreshToken();
const next = await this.repository.rotate({
previousSessionId: existing.id,
userId: user.id,
familyId: existing.familyId,
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
userAgent: context.userAgent,
ipAddress: context.ipAddress,
});
// Permissions are re-read from the database on every rotation, so a role
// change takes effect within one access-token lifetime rather than
// persisting for the life of the refresh token.
const access = await this.tokenService.issueAccessToken({
userId: user.id,
userType: user.type as UserType,
audience,
permissions: this.usersService.permissionsOf(user),
sessionId: next.id,
});
return {
result: {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt.toISOString(),
},
session: {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt,
refreshToken: refresh.token,
refreshTokenExpiresAt: refresh.expiresAt,
},
};
}
/** Signs out one device. Idempotent — an unknown token is still a success. */
async logout(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
const existing = await this.repository.findByRefreshHash(
this.tokenService.hashRefreshToken(refreshToken),
);
if (existing) {
await this.repository.revokeFamily(existing.familyId);
}
}
async me(userId: string): Promise<CurrentUser> {
const user = await this.usersService.findForAuthById(userId);
if (!user || user.status !== 'ACTIVE') {
throw AppException.unauthenticated();
}
return this.usersService.toCurrentUser(user);
}
async listSessions(userId: string, currentSessionId: string): Promise<SessionSummary[]> {
const rows = await this.repository.listActiveForUser(userId);
return rows.map((row) => ({
id: row.id,
userAgent: row.userAgent,
ipAddress: row.ipAddress,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
isCurrent: row.id === currentSessionId,
}));
}
// ---- internals -----------------------------------------------------------
private async startSession(
user: AuthUserRow,
audience: TokenAudience,
context: RequestContext,
): Promise<IssuedSession> {
const refresh = this.tokenService.issueRefreshToken();
const session = await this.repository.create({
userId: user.id,
// A fresh sign-in starts a new family; rotation stays within it. That is
// what keeps revoking one compromised device from signing out the rest.
familyId: this.tokenService.newSessionFamilyId(),
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
userAgent: context.userAgent,
ipAddress: context.ipAddress,
});
const access = await this.tokenService.issueAccessToken({
userId: user.id,
userType: user.type as UserType,
audience,
permissions: this.usersService.permissionsOf(user),
sessionId: session.id,
});
return {
accessToken: access.token,
accessTokenExpiresAt: access.expiresAt,
refreshToken: refresh.token,
refreshTokenExpiresAt: refresh.expiresAt,
};
}
/**
* A customer may never obtain an admin token, and a staff account may not
* sign in through the storefront form.
*
* Checked at issuance as well as at every request (AccessTokenGuard), because
* a token that should never have existed is worse than one that is merely
* rejected later.
*/
private assertAudience(userType: UserType, audience: TokenAudience): void {
const allowed =
audience === TOKEN_AUDIENCES.ADMIN ? isBackOfficeUser(userType) : userType === 'CUSTOMER';
if (!allowed) {
throw invalidCredentials();
}
}
}
/** One message, one status, for every failure mode. */
function invalidCredentials(): AppException {
return AppException.unauthenticated(
'Email or password is incorrect.',
API_ERROR_CODES.INVALID_CREDENTIALS,
);
}
@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { API_ERROR_CODES } from '@sport/types';
import { AppException } from '@/common/errors/app.exception';
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
/**
* Login-specific rate limiting, on top of the global per-IP throttle.
*
* Two counters, because they stop different attacks:
*
* - **per account** — someone guessing one user's password. A botnet spreads
* across many IPs, so an IP counter alone never trips.
* - **per IP** — someone spraying one common password across many accounts.
* An account counter alone never trips for that.
*
* Counting only failures means a busy legitimate user is never locked out, and
* a successful login clears the account counter.
*/
const MAX_FAILURES_PER_ACCOUNT = 8;
const MAX_FAILURES_PER_IP = 30;
const WINDOW_SECONDS = 15 * 60;
@Injectable()
export class LoginThrottleService {
private readonly logger = new Logger(LoginThrottleService.name);
constructor(private readonly redis: RedisService) {}
async assertNotLocked(email: string, ipAddress: string | null): Promise<void> {
const [accountFailures, ipFailures] = await Promise.all([
this.peek(CACHE_KEYS.rateLimit('login:account', email.toLowerCase())),
ipAddress ? this.peek(CACHE_KEYS.rateLimit('login:ip', ipAddress)) : Promise.resolve(0),
]);
if (accountFailures >= MAX_FAILURES_PER_ACCOUNT || ipFailures >= MAX_FAILURES_PER_IP) {
this.logger.warn(
`Login blocked by throttle (account failures: ${accountFailures}, ip failures: ${ipFailures})`,
);
throw new AppException({
code: API_ERROR_CODES.RATE_LIMITED,
message: 'Too many failed sign-in attempts. Please try again in a few minutes.',
status: 429,
});
}
}
async recordFailure(email: string, ipAddress: string | null): Promise<void> {
await Promise.all([
this.redis.increment(
CACHE_KEYS.rateLimit('login:account', email.toLowerCase()),
WINDOW_SECONDS,
),
ipAddress
? this.redis.increment(CACHE_KEYS.rateLimit('login:ip', ipAddress), WINDOW_SECONDS)
: Promise.resolve(0),
]);
}
/**
* Clears the account counter on success. The IP counter is deliberately left
* alone: one correct password should not reset a spray in progress from the
* same address.
*/
async recordSuccess(email: string): Promise<void> {
await this.redis.delete(CACHE_KEYS.rateLimit('login:account', email.toLowerCase()));
}
/**
* Reads a counter without incrementing. Redis being unavailable must not
* block sign-in — the global throttle and the password itself still apply.
*/
private async peek(key: string): Promise<number> {
try {
const value = await this.redis.get<number>(key);
return typeof value === 'number' ? value : 0;
} catch {
return 0;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Public surface of AuthModule.
*
* Deliberately narrow. `TokenService`, `AuthRepository` and the throttle stay
* private — nothing outside this module should be minting tokens or writing to
* the session table.
*
* Note that `PasswordService` is NOT here: it lives in `common/security`
* because UsersModule needs it too, and routing it through this module would
* create a dependency cycle.
*/
export { AuthService } from '../auth.service';
export type { RequestContext } from '../auth.service';
@@ -0,0 +1,78 @@
import type { CookieOptions, Request, Response } from 'express';
import type { TokenAudience } from '@sport/types';
/**
* Refresh-token cookie handling.
*
* Storefront and admin get *separately named* cookies. Sharing one name would
* mean signing into the admin silently replaces a customer session on the same
* browser — and worse, that a single cookie could be replayed against the other
* audience.
*/
const COOKIE_NAMES: Record<TokenAudience, string> = {
storefront: 'sport_refresh',
admin: 'sport_admin_refresh',
};
/**
* Scoped to the refresh endpoints only.
*
* The browser then sends this cookie on exactly two requests instead of
* attaching it to every API call — so an XSS that can read responses still
* never sees it, and it is not sitting in the headers of hundreds of unrelated
* requests waiting to be logged somewhere.
*/
const COOKIE_PATH = '/api/v1/auth';
export function refreshCookieName(audience: TokenAudience): string {
return COOKIE_NAMES[audience];
}
export function readRefreshCookie(request: Request, audience: TokenAudience): string | undefined {
const cookies = request.cookies as Record<string, string> | undefined;
return cookies?.[refreshCookieName(audience)];
}
export function setRefreshCookie(
response: Response,
audience: TokenAudience,
token: string,
expiresAt: Date,
isProduction: boolean,
): void {
response.cookie(refreshCookieName(audience), token, {
...baseOptions(isProduction),
expires: expiresAt,
});
}
export function clearRefreshCookie(
response: Response,
audience: TokenAudience,
isProduction: boolean,
): void {
response.clearCookie(refreshCookieName(audience), baseOptions(isProduction));
}
function baseOptions(isProduction: boolean): CookieOptions {
return {
// JavaScript cannot read it. This is the property that makes an XSS unable
// to steal the credential that mints new sessions.
httpOnly: true,
// HTTPS only in production. Local development runs on plain HTTP, and a
// Secure cookie would simply never be set — which looks like a broken
// login rather than a config choice.
secure: isProduction,
/**
* `lax` works because the frontends reach the API through their *own*
* origin — Nginx in production, a Next.js rewrite in development — so this
* is a first-party cookie. Cross-origin would force `SameSite=None`, which
* requires `Secure` and therefore cannot work over local HTTP at all.
*/
sameSite: 'lax',
path: COOKIE_PATH,
};
}
+127
View File
@@ -0,0 +1,127 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { AccessTokenClaims, Permission, TokenAudience, UserType } from '@sport/types';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
export interface IssuedAccessToken {
token: string;
expiresAt: Date;
}
export interface IssuedRefreshToken {
/** The value handed to the client. Never stored. */
token: string;
/** SHA-256 of the token — this is what the database keeps. */
hash: string;
expiresAt: Date;
}
/**
* Mints and verifies tokens. Holds no state; sessions live in AuthRepository.
*
* The two token types are deliberately different in kind:
*
* - The **access token** is a JWT. Stateless, short-lived, carries the
* permission set so guards do no database work on the hot path.
* - The **refresh token** is opaque random bytes, not a JWT. There is nothing
* for a client to read in it, and because it is checked against a database
* row it can be revoked — which a stateless JWT fundamentally cannot be.
*
* Only a SHA-256 of the refresh token is stored. A database leak therefore
* yields no usable credentials. SHA-256 rather than a password hash is correct
* here: the token is 256 bits of entropy, so there is no dictionary to attack
* and no reason to pay scrypt's cost on every refresh.
*/
@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async issueAccessToken(params: {
userId: string;
userType: UserType;
audience: TokenAudience;
permissions: readonly Permission[];
sessionId: string;
}): Promise<IssuedAccessToken> {
const expiresInSeconds = parseDuration(this.config.auth.accessTtl);
const token = await this.jwtService.signAsync(
{
sub: params.userId,
aud: params.audience,
type: params.userType,
permissions: params.permissions,
sid: params.sessionId,
} satisfies Omit<AccessTokenClaims, 'iat' | 'exp'>,
{
secret: this.config.auth.accessSecret,
issuer: this.config.auth.issuer,
expiresIn: expiresInSeconds,
},
);
return { token, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) };
}
issueRefreshToken(): IssuedRefreshToken {
// 32 bytes = 256 bits. base64url so it is cookie- and URL-safe without
// escaping.
const token = randomBytes(32).toString('base64url');
return {
token,
hash: this.hashRefreshToken(token),
expiresAt: new Date(Date.now() + parseDuration(this.config.auth.refreshTtl) * 1000),
};
}
hashRefreshToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
newSessionFamilyId(): string {
return randomUUID();
}
get refreshTtlSeconds(): number {
return parseDuration(this.config.auth.refreshTtl);
}
}
/**
* `15m` → 900. The env schema already guarantees the format, so an unparseable
* value here means the validator and this function have drifted — which should
* fail loudly at boot rather than silently issue an eternal token.
*/
export function parseDuration(value: string): number {
const match = /^(\d+)(ms|s|m|h|d)$/.exec(value);
if (!match) {
throw new Error(`Invalid duration: ${value}`);
}
const amount = Number(match[1]);
const unit = match[2];
switch (unit) {
case 'ms':
return Math.ceil(amount / 1000);
case 's':
return amount;
case 'm':
return amount * 60;
case 'h':
return amount * 3600;
case 'd':
return amount * 86_400;
default:
throw new Error(`Invalid duration unit: ${String(unit)}`);
}
}
+5 -6
View File
@@ -1,10 +1,9 @@
/**
* Public surface of UsersModule.
*
* This barrel is the ONLY thing other modules may import from here. Everything
* else — repository, DTOs, internal services — is private, and the ESLint
* boundary rule in @sport/eslint-config/nest enforces it.
*
* Keep it narrow: each export is a promise to the rest of the codebase.
* AuthModule consumes `UsersService` for account lookup, permission resolution
* and login bookkeeping. The repositories, the mapper and every Prisma row type
* stay private.
*/
export {};
export { UsersService } from '../users.service';
export type { AuthUserRow } from '../users.repository';
@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PERMISSIONS, TOKEN_AUDIENCES, type PermissionGroup, type RoleDetail } from '@sport/types';
import {
createRoleSchema,
updateRoleSchema,
type CreateRoleInput,
type UpdateRoleInput,
} from '@sport/validation';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { RolesService } from './roles.service';
@ApiTags('admin/roles')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get()
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'List roles with their permissions and user counts' })
list(): Promise<RoleDetail[]> {
return this.rolesService.list();
}
/**
* Declared before `:id` so the literal path wins — otherwise
* `/admin/roles/permissions` resolves as a role with the id "permissions".
*/
@Get('permissions')
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'The permission catalog, grouped by resource' })
listPermissions(): Promise<PermissionGroup[]> {
return this.rolesService.listPermissions();
}
@Get(':id')
@RequirePermissions(PERMISSIONS.ROLE_READ)
@ApiOperation({ summary: 'Get one role' })
getById(@Param('id') id: string): Promise<RoleDetail> {
return this.rolesService.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@ApiOperation({ summary: 'Create a role' })
create(
@Body(new ZodValidationPipe(createRoleSchema)) body: CreateRoleInput,
): Promise<RoleDetail> {
return this.rolesService.create(body);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@ApiOperation({ summary: "Update a role's name, description or permissions" })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateRoleSchema)) body: UpdateRoleInput,
): Promise<RoleDetail> {
return this.rolesService.update(id, body);
}
@Delete(':id')
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a non-system role that nobody holds' })
delete(@Param('id') id: string): Promise<void> {
return this.rolesService.delete(id);
}
}
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
const roleSelect = {
id: true,
key: true,
name: true,
description: true,
isSystem: true,
permissions: { select: { permission: { select: { key: true } } } },
} as const;
@Injectable()
export class RolesRepository {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.role.findMany({
select: { ...roleSelect, _count: { select: { users: true } } },
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
});
}
findById(id: string) {
return this.prisma.role.findUnique({
where: { id },
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
findByKey(key: string) {
return this.prisma.role.findUnique({ where: { key }, select: roleSelect });
}
findManyByIds(ids: readonly string[]) {
return this.prisma.role.findMany({ where: { id: { in: [...ids] } }, select: { id: true } });
}
listPermissions() {
return this.prisma.permission.findMany({
select: { key: true, resource: true, action: true },
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
});
}
/**
* Creates a role and its grants in one transaction.
*
* Permission keys are resolved to ids here rather than trusted from the
* client: an unknown key is dropped instead of silently creating a permission
* that no guard will ever check.
*/
async create(input: {
key: string;
name: string;
description: string | null;
permissionKeys: readonly string[];
}) {
const permissionIds = await this.resolvePermissionIds(input.permissionKeys);
return this.prisma.role.create({
data: {
key: input.key,
name: input.name,
description: input.description,
isSystem: false,
permissions: {
createMany: { data: permissionIds.map((permissionId) => ({ permissionId })) },
},
},
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
async update(
id: string,
input: { name?: string; description?: string | null; permissionKeys?: readonly string[] },
) {
if (input.permissionKeys) {
const permissionIds = await this.resolvePermissionIds(input.permissionKeys);
// Replace the grant set wholesale inside a transaction — a half-applied
// permission change is a security incident, not a glitch.
await this.prisma.$transaction([
this.prisma.rolePermission.deleteMany({ where: { roleId: id } }),
this.prisma.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
skipDuplicates: true,
}),
]);
}
return this.prisma.role.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.description === undefined ? {} : { description: input.description }),
},
select: { ...roleSelect, _count: { select: { users: true } } },
});
}
delete(id: string) {
return this.prisma.role.delete({ where: { id }, select: { id: true } });
}
private async resolvePermissionIds(keys: readonly string[]): Promise<string[]> {
if (keys.length === 0) return [];
const rows = await this.prisma.permission.findMany({
where: { key: { in: [...keys] } },
select: { id: true },
});
return rows.map((row) => row.id);
}
}
export type RoleRow = NonNullable<Awaited<ReturnType<RolesRepository['findById']>>>;
+120
View File
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import { ALL_PERMISSIONS, type PermissionGroup, type RoleDetail } from '@sport/types';
import type { CreateRoleInput, UpdateRoleInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { RolesRepository } from './roles.repository';
import { UsersMapper } from './users.mapper';
@Injectable()
export class RolesService {
constructor(
private readonly repository: RolesRepository,
private readonly mapper: UsersMapper,
) {}
async list(): Promise<RoleDetail[]> {
const rows = await this.repository.findAll();
return rows.map((row) => this.mapper.toRoleDetail(row));
}
async getById(id: string): Promise<RoleDetail> {
const row = await this.repository.findById(id);
if (!row) throw AppException.notFound('Role');
return this.mapper.toRoleDetail(row);
}
/**
* The permission catalog, served from the database.
*
* The seed reconciles this table against the code catalog in @sport/types, so
* what the role editor shows is exactly what the running guards enforce — a
* deploy skew surfaces as a missing checkbox rather than a grant that silently
* does nothing.
*/
async listPermissions(): Promise<PermissionGroup[]> {
const rows = await this.repository.listPermissions();
return this.mapper.toPermissionGroups(rows);
}
async create(input: CreateRoleInput): Promise<RoleDetail> {
const existing = await this.repository.findByKey(input.key);
if (existing) {
throw AppException.conflict('A role with that key already exists.');
}
const row = await this.repository.create({
key: input.key,
name: input.name,
description: input.description ?? null,
permissionKeys: this.assertKnownPermissions(input.permissions),
});
return this.mapper.toRoleDetail(row);
}
async update(id: string, input: UpdateRoleInput): Promise<RoleDetail> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('Role');
/**
* System roles may have their permissions edited but not their identity.
*
* The seed reconciles system roles from code on every run, so a renamed key
* would be silently recreated — and an operator would be left wondering why
* their change vanished. Rejecting it is clearer than losing it.
*/
if (existing.isSystem && input.name !== undefined && input.name !== existing.name) {
throw AppException.badRequest('System roles cannot be renamed.');
}
const row = await this.repository.update(id, {
...(input.name === undefined ? {} : { name: input.name }),
...(input.description === undefined ? {} : { description: input.description ?? null }),
...(input.permissions === undefined
? {}
: { permissionKeys: this.assertKnownPermissions(input.permissions) }),
});
return this.mapper.toRoleDetail(row);
}
async delete(id: string): Promise<void> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('Role');
if (existing.isSystem) {
throw AppException.badRequest('System roles cannot be deleted.');
}
// Deleting a role that people hold would silently strip their access.
// Making the operator reassign first keeps the consequence visible.
if (existing._count.users > 0) {
throw AppException.conflict(
`This role is assigned to ${existing._count.users} user(s). Reassign them first.`,
);
}
await this.repository.delete(id);
}
/**
* Rejects permission keys the code does not define.
*
* Without this a typo'd key would be stored, displayed as granted, and never
* match a guard — an access-control bug that looks like working configuration.
*/
private assertKnownPermissions(keys: readonly string[]): string[] {
const known = new Set<string>(ALL_PERMISSIONS);
const unknown = keys.filter((key) => !known.has(key));
if (unknown.length > 0) {
throw AppException.badRequest(`Unknown permission(s): ${unknown.join(', ')}`);
}
return [...new Set(keys)];
}
}
@@ -0,0 +1,111 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PERMISSIONS, TOKEN_AUDIENCES, type OffsetPaginated, type UserSummary } from '@sport/types';
import {
createUserSchema,
resetUserPasswordSchema,
updateUserSchema,
userListQuerySchema,
type CreateUserInput,
type UpdateUserInput,
type UserListQuery,
} from '@sport/validation';
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
import {
RequireAudience,
RequirePermissions,
} from '@/common/decorators/require-permissions.decorator';
import { AppException } from '@/common/errors/app.exception';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { PasswordService } from '@/common/security/password.service';
import { UsersService } from './users.service';
/**
* Back-office user administration.
*
* `@RequireAudience('admin')` on the controller means a storefront token is
* rejected before any permission is even read — defence in depth, not an
* optimisation.
*/
@ApiTags('admin/users')
@ApiBearerAuth()
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
@Controller('admin/users')
export class UsersController {
constructor(
private readonly usersService: UsersService,
private readonly passwordService: PasswordService,
) {}
@Get()
@RequirePermissions(PERMISSIONS.USER_READ)
@ApiOperation({ summary: 'List back-office users' })
list(
@Query(new ZodValidationPipe(userListQuerySchema)) query: UserListQuery,
): Promise<OffsetPaginated<UserSummary>> {
return this.usersService.list(query);
}
@Get(':id')
@RequirePermissions(PERMISSIONS.USER_READ)
@ApiOperation({ summary: 'Get one user' })
getById(@Param('id') id: string): Promise<UserSummary> {
return this.usersService.getById(id);
}
@Post()
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: 'Create a back-office user' })
async create(
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserInput,
): Promise<UserSummary> {
const passwordHash = await this.passwordService.hash(body.password);
return this.usersService.create(body, passwordHash);
}
@Patch(':id')
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: 'Update a user, including their roles' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserInput,
@CurrentActor() actor: { userId: string },
): Promise<UserSummary> {
/**
* An operator cannot change their own type, status or roles.
*
* This is the lockout guard: without it, an admin can demote themselves out
* of the very permission needed to undo it, and the only recovery is a
* database console.
*/
if (id === actor.userId) {
const touchesOwnAccess =
body.roleIds !== undefined || body.status !== undefined || body.type !== undefined;
if (touchesOwnAccess) {
throw AppException.forbidden('You cannot change your own roles, type or status.');
}
}
return this.usersService.update(id, body);
}
@Post(':id/password')
@RequirePermissions(PERMISSIONS.USER_MANAGE)
@ApiOperation({ summary: "Reset another user's password" })
async resetPassword(
@Param('id') id: string,
@Body(new ZodValidationPipe(resetUserPasswordSchema)) body: { password: string },
): Promise<{ ok: true }> {
await this.usersService.getById(id);
await this.usersService.updatePasswordHash(id, await this.passwordService.hash(body.password));
// NOTE: existing sessions are intentionally NOT revoked here yet. Doing it
// properly means revoking every session family for the user, which belongs
// with the session-management screen rather than bolted on here.
return { ok: true };
}
}
+112
View File
@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import type {
CurrentUser,
Permission,
PermissionGroup,
RoleDetail,
UserStatus,
UserSummary,
UserType,
} from '@sport/types';
import { MediaUrlService } from '@/common/media/media-url.service';
import type { RoleRow } from './roles.repository';
import type { AuthUserRow, UserRow } from './users.repository';
@Injectable()
export class UsersMapper {
constructor(private readonly mediaUrl: MediaUrlService) {}
/**
* Flattens role grants into a distinct permission set.
*
* Roles are additive and may overlap — a user with both "Catalog Manager" and
* "Order Manager" gets the union, deduplicated. Nothing subtracts, which is
* what keeps "why can this person do X?" answerable by listing their roles.
*/
permissionsOf(row: AuthUserRow): Permission[] {
const permissions = new Set<string>();
for (const link of row.roles) {
for (const grant of link.role.permissions) {
permissions.add(grant.permission.key);
}
}
return [...permissions] as Permission[];
}
roleKeysOf(row: AuthUserRow): string[] {
return row.roles.map((link) => link.role.key);
}
toCurrentUser(row: AuthUserRow): CurrentUser {
return {
id: row.id,
email: row.email,
type: row.type as UserType,
firstName: row.firstName,
lastName: row.lastName,
displayName: displayName(row.firstName, row.lastName, row.email),
avatarUrl: row.avatar ? this.mediaUrl.url(row.avatar.storageKey) : null,
roles: this.roleKeysOf(row),
permissions: this.permissionsOf(row),
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
};
}
toSummary(row: UserRow): UserSummary {
return {
id: row.id,
email: row.email,
type: row.type as UserType,
status: row.status as UserStatus,
firstName: row.firstName,
lastName: row.lastName,
displayName: displayName(row.firstName, row.lastName, row.email),
roles: row.roles.map((link) => ({
id: link.role.id,
key: link.role.key,
name: link.role.name,
isSystem: link.role.isSystem,
})),
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
toRoleDetail(row: RoleRow): RoleDetail {
return {
id: row.id,
key: row.key,
name: row.name,
description: row.description,
isSystem: row.isSystem,
permissions: row.permissions.map((grant) => grant.permission.key as Permission),
userCount: row._count.users,
};
}
/** Groups the catalog by resource so the role editor renders as sections. */
toPermissionGroups(
rows: readonly { key: string; resource: string; action: string }[],
): PermissionGroup[] {
const groups = new Map<string, PermissionGroup['permissions'][number][]>();
for (const row of rows) {
const bucket = groups.get(row.resource) ?? [];
bucket.push({ key: row.key as Permission, resource: row.resource, action: row.action });
groups.set(row.resource, bucket);
}
return [...groups.entries()].map(([resource, permissions]) => ({ resource, permissions }));
}
}
/** Falls back to the email so a row never renders as an empty name. */
function displayName(firstName: string | null, lastName: string | null, email: string): string {
const full = [firstName, lastName].filter(Boolean).join(' ').trim();
return full.length > 0 ? full : email;
}
+18 -13
View File
@@ -1,19 +1,24 @@
import { Module } from '@nestjs/common';
import { RolesController } from './roles.controller';
import { RolesRepository } from './roles.repository';
import { RolesService } from './roles.service';
import { UsersController } from './users.controller';
import { UsersMapper } from './users.mapper';
import { UsersRepository } from './users.repository';
import { UsersService } from './users.service';
/**
* UsersModule — boundary declared, implementation pending.
* UsersModule — owns `users`, `roles`, `permissions`, `role_permissions` and
* `user_roles`.
*
* Owns (exclusively): `users`, `roles`, `permissions`, `role_permissions`, `user_roles`
*
* Back-office identity and the RBAC administration surface. Owns the role/permission tables that AuthModule only reads through this module.
*
* Anatomy once implemented (see ../README.md):
* users.module.ts wiring only
* users.controller.ts HTTP surface, no logic
* users.service.ts business rules
* users.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
* Back-office identity plus the RBAC administration surface. AuthModule reads
* accounts through this module's public service; it never queries `users`
* itself, which keeps credential exchange and account management separable.
*/
@Module({})
@Module({
controllers: [UsersController, RolesController],
providers: [UsersService, RolesService, UsersRepository, RolesRepository, UsersMapper],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,152 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { UserListQuery } from '@sport/validation';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
/** Everything needed to authenticate, in one query. */
const authSelect = {
id: true,
email: true,
passwordHash: true,
type: true,
status: true,
firstName: true,
lastName: true,
lastLoginAt: true,
avatar: { select: { storageKey: true } },
roles: {
select: {
role: {
select: {
key: true,
permissions: { select: { permission: { select: { key: true } } } },
},
},
},
},
} as const;
const summarySelect = {
id: true,
email: true,
type: true,
status: true,
firstName: true,
lastName: true,
lastLoginAt: true,
createdAt: true,
roles: {
select: { role: { select: { id: true, key: true, name: true, isSystem: true } } },
},
} as const;
@Injectable()
export class UsersRepository {
constructor(private readonly prisma: PrismaService) {}
/**
* Soft-deleted accounts are invisible everywhere. Filtering here rather than
* at each call site means a forgotten `deletedAt: null` cannot resurrect a
* removed operator.
*/
private alive(): Prisma.UserWhereInput {
return { deletedAt: null };
}
findForAuthByEmail(email: string) {
return this.prisma.user.findFirst({
// Emails are normalised to lowercase on write (@sport/validation), so a
// plain equality match is correct and uses the unique index.
where: { ...this.alive(), email: email.toLowerCase() },
select: authSelect,
});
}
findForAuthById(id: string) {
return this.prisma.user.findFirst({ where: { ...this.alive(), id }, select: authSelect });
}
async list(query: UserListQuery) {
const where: Prisma.UserWhereInput = {
...this.alive(),
...(query.type ? { type: query.type } : {}),
...(query.status ? { status: query.status } : {}),
...(query.q
? {
OR: [
{ email: { contains: query.q, mode: 'insensitive' } },
{ firstName: { contains: query.q, mode: 'insensitive' } },
{ lastName: { contains: query.q, mode: 'insensitive' } },
],
}
: {}),
};
const [items, totalItems] = await Promise.all([
this.prisma.user.findMany({
where,
select: summarySelect,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.perPage,
take: query.perPage,
}),
this.prisma.user.count({ where }),
]);
return { items, totalItems };
}
findById(id: string) {
return this.prisma.user.findFirst({ where: { ...this.alive(), id }, select: summarySelect });
}
create(data: Prisma.UserCreateInput) {
return this.prisma.user.create({ data, select: summarySelect });
}
update(id: string, data: Prisma.UserUpdateInput) {
return this.prisma.user.update({ where: { id }, data, select: summarySelect });
}
/**
* Replaces a user's role set atomically.
*
* Delete-then-insert inside one transaction, because a partial application
* would briefly leave an operator with fewer — or worse, more — permissions
* than intended.
*/
async setRoles(userId: string, roleIds: readonly string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.userRole.deleteMany({ where: { userId } }),
this.prisma.userRole.createMany({
data: roleIds.map((roleId) => ({ userId, roleId })),
skipDuplicates: true,
}),
]);
}
recordLogin(id: string) {
return this.prisma.user.update({
where: { id },
data: { lastLoginAt: new Date() },
select: { id: true },
});
}
updatePasswordHash(id: string, passwordHash: string) {
return this.prisma.user.update({
where: { id },
data: { passwordHash },
select: { id: true },
});
}
countByRole(roleId: string) {
return this.prisma.userRole.count({ where: { roleId } });
}
}
export type AuthUserRow = NonNullable<Awaited<ReturnType<UsersRepository['findForAuthByEmail']>>>;
export type UserRow = NonNullable<Awaited<ReturnType<UsersRepository['findById']>>>;
+142
View File
@@ -0,0 +1,142 @@
import { Injectable } from '@nestjs/common';
import {
API_ERROR_CODES,
type CurrentUser,
type OffsetPaginated,
type Permission,
type UserSummary,
} from '@sport/types';
import type { CreateUserInput, UpdateUserInput, UserListQuery } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { RolesRepository } from './roles.repository';
import { UsersMapper } from './users.mapper';
import { UsersRepository, type AuthUserRow } from './users.repository';
/**
* Identity data: who exists, what they are, which roles they hold.
*
* AuthModule owns the *exchange* of credentials for tokens and the session
* table; this module owns the accounts themselves. Auth reads through the
* public surface below rather than querying `users` directly, which is what
* keeps password handling and account management from bleeding into each other.
*/
@Injectable()
export class UsersService {
constructor(
private readonly repository: UsersRepository,
private readonly rolesRepository: RolesRepository,
private readonly mapper: UsersMapper,
) {}
// ---- Consumed by AuthModule ---------------------------------------------
findForAuthByEmail(email: string): Promise<AuthUserRow | null> {
return this.repository.findForAuthByEmail(email);
}
findForAuthById(id: string): Promise<AuthUserRow | null> {
return this.repository.findForAuthById(id);
}
permissionsOf(row: AuthUserRow): Permission[] {
return this.mapper.permissionsOf(row);
}
toCurrentUser(row: AuthUserRow): CurrentUser {
return this.mapper.toCurrentUser(row);
}
async recordLogin(userId: string): Promise<void> {
await this.repository.recordLogin(userId);
}
async updatePasswordHash(userId: string, passwordHash: string): Promise<void> {
await this.repository.updatePasswordHash(userId, passwordHash);
}
// ---- Back-office user management ----------------------------------------
async list(query: UserListQuery): Promise<OffsetPaginated<UserSummary>> {
const { items, totalItems } = await this.repository.list(query);
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
return {
items: items.map((item) => this.mapper.toSummary(item)),
pageInfo: {
page: query.page,
perPage: query.perPage,
totalItems,
totalPages,
hasNextPage: query.page < totalPages,
},
};
}
async getById(id: string): Promise<UserSummary> {
const row = await this.repository.findById(id);
if (!row) throw AppException.notFound('User');
return this.mapper.toSummary(row);
}
async create(input: CreateUserInput, passwordHash: string): Promise<UserSummary> {
const existing = await this.repository.findForAuthByEmail(input.email);
if (existing) {
throw AppException.conflict('An account with that email already exists.');
}
await this.assertRolesExist(input.roleIds);
const user = await this.repository.create({
email: input.email,
passwordHash,
type: input.type,
status: 'ACTIVE',
firstName: input.firstName,
lastName: input.lastName,
phone: input.phone ?? null,
roles: { createMany: { data: input.roleIds.map((roleId) => ({ roleId })) } },
});
return this.getById(user.id);
}
async update(id: string, input: UpdateUserInput): Promise<UserSummary> {
const existing = await this.repository.findById(id);
if (!existing) throw AppException.notFound('User');
if (input.roleIds) {
await this.assertRolesExist(input.roleIds);
await this.repository.setRoles(id, input.roleIds);
}
await this.repository.update(id, {
...(input.firstName === undefined ? {} : { firstName: input.firstName }),
...(input.lastName === undefined ? {} : { lastName: input.lastName }),
...(input.phone === undefined ? {} : { phone: input.phone ?? null }),
...(input.status === undefined ? {} : { status: input.status }),
...(input.type === undefined ? {} : { type: input.type }),
});
return this.getById(id);
}
/**
* Rejects unknown role ids rather than silently ignoring them.
*
* A create that quietly drops a role leaves an operator convinced they
* granted access that was never granted — the worst possible failure mode for
* a permissions screen.
*/
private async assertRolesExist(roleIds: readonly string[]): Promise<void> {
if (roleIds.length === 0) return;
const found = await this.rolesRepository.findManyByIds(roleIds);
if (found.length !== new Set(roleIds).size) {
throw AppException.badRequest('One or more roles do not exist.', API_ERROR_CODES.BAD_REQUEST);
}
}
}
+40 -4
View File
@@ -12,6 +12,20 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig: NextConfig = {
reactStrictMode: true,
/**
* Proxies API calls through this app's own origin.
*
* The refresh token is a `SameSite=Lax` httpOnly cookie, so the browser only
* sends it first-party. Calling the API host directly from the browser would
* mean `SameSite=None; Secure`, which cannot work over plain HTTP in local
* development at all. Production does the same thing at the Nginx layer, so
* dev and prod share one topology instead of two.
*/
async rewrites() {
const target = process.env.API_INTERNAL_URL ?? 'http://localhost:4000';
return [{ source: '/api/:path*', destination: `${target}/api/:path*` }];
},
/**
* Workspace packages ship TypeScript source rather than a build artefact, so
* Next compiles them with the app. No watch-and-rebuild step during local
@@ -23,13 +37,35 @@ const nextConfig: NextConfig = {
typedRoutes: true,
images: {
// Media is served from R2/CDN. Locally that is MinIO.
/**
* Media is served from R2/CDN in production and MinIO locally.
*
* `pathname` and `search` are specified explicitly: Next 16 matches remote
* patterns strictly, and an entry without them does not authorise the URL —
* the optimizer answers `"url" parameter is not allowed` and every product
* image renders broken. Scoping to the bucket path also keeps this from
* becoming an open image proxy.
*/
remotePatterns: [
{ protocol: 'http', hostname: 'localhost', port: '9000' },
{ protocol: 'https', hostname: '**.r2.dev' },
{ protocol: 'https', hostname: 'cdn.sport-store.local' },
{ protocol: 'http', hostname: 'localhost', port: '9000', pathname: '/**', search: '' },
{ protocol: 'https', hostname: '**.r2.dev', pathname: '/**', search: '' },
{ protocol: 'https', hostname: 'cdn.sport-store.local', pathname: '/**', search: '' },
],
formats: ['image/avif', 'image/webp'],
/**
* DEVELOPMENT ONLY.
*
* Next 16 refuses to fetch an upstream image whose hostname resolves to a
* private IP — an SSRF precaution — and reports it as `"url" parameter is
* not allowed`, the same message it uses for an unmatched remote pattern.
* That shared message is what makes this so easy to misdiagnose.
*
* Local MinIO lives on `localhost:9000`, so the guard blocks every product
* image in development. It stays ON in production, where media is served
* from a public CDN host and the protection is exactly what we want.
*/
dangerouslyAllowLocalIP: process.env.NODE_ENV !== 'production',
},
// Standalone output keeps the production image small (no node_modules copy).
+15 -3
View File
@@ -1,6 +1,6 @@
import { createApiClient, type RequestOptions } from '@sport/api-client';
import { clientEnv, getServerEnv } from './env';
import { getServerEnv } from './env';
/**
* Two clients, because the two runtimes have different needs:
@@ -22,9 +22,21 @@ export function getServerApi() {
});
}
/**
* Browser client.
*
* `baseUrl: ''` means same-origin: requests go through this app's own host and
* are proxied to the API (Next rewrite in development, Nginx in production).
* That is what makes the refresh cookie first-party — see ADR-0015 and the
* `rewrites()` comment in next.config.ts.
*
* Pointing this at the API host directly would work for anonymous catalog reads
* and then break the moment customer sign-in lands in M8, which is precisely
* the kind of latent inconsistency worth removing now.
*/
export const browserApi = createApiClient({
baseUrl: clientEnv.NEXT_PUBLIC_API_URL,
getAccessToken: () => null, // wired to the auth store in the auth milestone
baseUrl: '',
getAccessToken: () => null, // wired to the customer auth store in M8
});
/**