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