Stage M2
This commit is contained in:
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
A modern sports-fashion e-commerce platform.
|
A modern sports-fashion e-commerce platform.
|
||||||
|
|
||||||
**Status: milestone 1 — catalog read API, live end to end, in Vietnamese and English.**
|
**Status: milestone 2 — auth and RBAC, on top of a live bilingual catalog.**
|
||||||
The storefront renders real products from the database through the REST API. Cart, checkout,
|
The storefront renders real products in Vietnamese and English; the admin has working sign-in
|
||||||
orders and auth are still ahead; see [Roadmap](#roadmap).
|
with rotating refresh tokens and permission-filtered navigation. Cart, checkout and orders are
|
||||||
|
next; see [Roadmap](#roadmap).
|
||||||
|
|
||||||
```
|
```
|
||||||
Storefront (Next.js) ─┐
|
Storefront (Next.js) ─┐
|
||||||
@@ -97,8 +98,13 @@ pnpm db:generate # regenerate Prisma Client
|
|||||||
pnpm db:seed # reconcile permissions + roles (idempotent)
|
pnpm db:seed # reconcile permissions + roles (idempotent)
|
||||||
pnpm db:studio # Prisma Studio
|
pnpm db:studio # Prisma Studio
|
||||||
pnpm db:reset # drop, re-migrate, re-seed
|
pnpm db:reset # drop, re-migrate, re-seed
|
||||||
|
pnpm db:create-admin # create/repair a SUPER_ADMIN (prints a generated password)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`pnpm db:seed` also creates three **development** sign-in accounts and prints their generated
|
||||||
|
passwords once. They are skipped when `NODE_ENV=production` or `SEED_DEMO=false`; real
|
||||||
|
environments use `pnpm db:create-admin`, which is also the lockout-recovery path.
|
||||||
|
|
||||||
### Running one app manually
|
### Running one app manually
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -153,7 +159,7 @@ sport-store/
|
|||||||
│
|
│
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── architecture.md Boundaries, conventions, risks — read this first
|
│ ├── architecture.md Boundaries, conventions, risks — read this first
|
||||||
│ └── adr/ 14 decision records
|
│ └── adr/ 15 decision records
|
||||||
│
|
│
|
||||||
├── docker-compose.yml Backing services; `--profile full` runs everything
|
├── docker-compose.yml Backing services; `--profile full` runs everything
|
||||||
├── turbo.json pnpm-workspace.yaml package.json
|
├── turbo.json pnpm-workspace.yaml package.json
|
||||||
@@ -194,6 +200,11 @@ Full detail in [`docs/architecture.md`](./docs/architecture.md). The rules that
|
|||||||
is why most "add a language" projects end up half-translated.
|
is why most "add a language" projects end up half-translated.
|
||||||
([ADR-0013](./docs/adr/0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md))
|
([ADR-0013](./docs/adr/0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md))
|
||||||
|
|
||||||
|
9. **The browser always calls the API on its own origin** — Nginx in production, a Next rewrite
|
||||||
|
in development. That is what makes the httpOnly refresh cookie first-party, and what keeps
|
||||||
|
dev and production authenticating identically.
|
||||||
|
([ADR-0015](./docs/adr/0015-frontends-reach-the-api-through-their-own-origin.md))
|
||||||
|
|
||||||
### Languages
|
### Languages
|
||||||
|
|
||||||
Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`.
|
Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`.
|
||||||
@@ -231,7 +242,7 @@ locale-in-path would buy nothing.
|
|||||||
| --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI |
|
| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI |
|
||||||
| **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data |
|
| **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data |
|
||||||
| **M2** | Auth: login, refresh rotation, RBAC admin, user/role management |
|
| **M2** ✅ | Auth: login, refresh rotation with reuse detection, RBAC admin, user & role management |
|
||||||
| **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory |
|
| **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory |
|
||||||
| **M4** ◐ | Storefront catalog — listings, PDP, variant selector and filters landed with M1; sort UI, pagination and a mobile filter drawer remain |
|
| **M4** ◐ | Storefront catalog — listings, PDP, variant selector and filters landed with M1; sort UI, pagination and a mobile filter drawer remain |
|
||||||
| **M5** | Cart, checkout, orders |
|
| **M5** | Cart, checkout, orders |
|
||||||
@@ -240,10 +251,10 @@ locale-in-path would buy nothing.
|
|||||||
| **M8** | Customer account |
|
| **M8** | Customer account |
|
||||||
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
|
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
|
||||||
|
|
||||||
**Recommended next step: M2 (auth).** The enforcement half already exists — global access-token
|
**Recommended next step: M3 (admin catalog write path).** Reads, auth and RBAC are in place, so
|
||||||
guard, RBAC permissions guard, audience separation — so only issuance is missing: login, refresh
|
the product editor and variant matrix now have everything they need — a known operator, a
|
||||||
rotation, and the admin user/role screens. Everything after it (cart ownership, orders, the admin
|
permission to check, and a catalog to edit. It is also what makes the seed replaceable by real
|
||||||
write path) depends on knowing who is asking.
|
merchandising.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -253,17 +264,35 @@ Everything below was run, not assumed:
|
|||||||
|
|
||||||
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 25/25 Turborepo tasks pass;
|
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 25/25 Turborepo tasks pass;
|
||||||
`pnpm format:check` clean
|
`pnpm format:check` clean
|
||||||
- 5 migrations applied; 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
|
- 5 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
|
||||||
3 collections, 12 products, **155 variants** and 64 generated images uploaded to MinIO
|
3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
|
||||||
- `pnpm test` — 17 passing (RBAC guards, translation fallback, `Accept-Language` negotiation)
|
- **25 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`
|
||||||
- API: listings with filters/facets/cursor paging, PDP, navigation, brands and collections all
|
- Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in
|
||||||
return correctly localised payloads in both `vi` and `en`
|
both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`)
|
||||||
- Storefront: every route returns 200 in both locales; PDP renders translated options, spec
|
- Storefront: every route 200 in both locales; `/en/products/<vi-slug>` → 307 →
|
||||||
table and variant titles; `/en/products/<vi-slug>` → 307 → `/en/products/<en-slug>`;
|
`/en/products/<en-slug>`; `hreflang` + canonical emitted per locale
|
||||||
`hreflang` + canonical emitted per locale
|
- **Auth:** admin sign-in works through the app's own origin; the refresh cookie is httpOnly and
|
||||||
- Money formats per locale from one integer: `690.000 ₫` (vi) / `₫690,000` (en)
|
scoped to `/api/v1/auth`; refresh rotates the token; **replaying a rotated token is rejected and
|
||||||
|
revokes the entire family** (verified: 2 of 3 sessions revoked)
|
||||||
|
- **Audience isolation:** a customer cannot sign in at the admin endpoint and an admin cannot sign
|
||||||
|
in at the storefront endpoint — both return the same `INVALID_CREDENTIALS` as a wrong password,
|
||||||
|
so the form cannot be used to enumerate accounts
|
||||||
|
- **RBAC:** a `catalog_manager` receives `PERMISSION_DENIED` on `/admin/users` and `/admin/roles`;
|
||||||
|
no token gives `UNAUTHENTICATED`
|
||||||
- Admin: renders Vietnamese by default and English with `sport_admin_locale=en`
|
- Admin: renders Vietnamese by default and English with `sport_admin_locale=en`
|
||||||
|
|
||||||
|
### Verified in a real browser
|
||||||
|
|
||||||
|
Server-side checks and curl are not sufficient for client behaviour — three bugs proved it.
|
||||||
|
Confirmed by clicking through Chrome with the console and network panel open:
|
||||||
|
|
||||||
|
- Admin sign-in issues exactly **one** `POST /auth/admin/login`, then redirects to the dashboard
|
||||||
|
- Sidebar is filtered by the signed-in operator's permissions; users table and role viewer load
|
||||||
|
real data; language switch preserves the session and the current page; sign-out returns to login
|
||||||
|
- Storefront PDP: gallery swaps with the colourway, per-variant stock disables the right sizes,
|
||||||
|
SKU updates, and switching language moves between translated slugs
|
||||||
|
- Filters apply (`/men?colors=black&onSale=true`), and all 16 grid images load
|
||||||
|
|
||||||
Known benign noise: NestJS logs two `Unsupported route path: "/api/*"` warnings at boot. They
|
Known benign noise: NestJS logs two `Unsupported route path: "/api/*"` warnings at boot. They
|
||||||
come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are
|
come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are
|
||||||
auto-converted correctly, and routing is verified working. Nothing in this repository registers
|
auto-converted correctly, and routing is verified working. Nothing in this repository registers
|
||||||
|
|||||||
@@ -6,16 +6,48 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
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'],
|
transpilePackages: ['@sport/ui'],
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
|
||||||
images: {
|
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: [
|
remotePatterns: [
|
||||||
{ protocol: 'http', hostname: 'localhost', port: '9000' },
|
{ protocol: 'http', hostname: 'localhost', port: '9000', pathname: '/**', search: '' },
|
||||||
{ protocol: 'https', hostname: '**.r2.dev' },
|
{ protocol: 'https', hostname: '**.r2.dev', pathname: '/**', search: '' },
|
||||||
{ protocol: 'https', hostname: 'cdn.sport-store.local' },
|
{ 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.
|
// 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 { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||||
|
import { LoginForm } from '@/features/auth/login-form';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = await getTranslations('common.login');
|
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
|
* Outside the dashboard route group, so it renders without the sidebar and
|
||||||
* without the auth requirement.
|
* without the session requirement.
|
||||||
*/
|
*/
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage() {
|
||||||
const t = await getTranslations('common');
|
const t = await getTranslations('common');
|
||||||
@@ -18,13 +19,16 @@ export default async function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-ink-50 flex min-h-screen items-center justify-center px-6">
|
<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="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">
|
<h1 className="text-lg font-black uppercase tracking-tighter">
|
||||||
Sport<span className="text-volt-600">.</span> {t('appName')}
|
Sport<span className="text-volt-600">.</span> {t('appName')}
|
||||||
</h1>
|
</h1>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</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>
|
||||||
</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 { 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.
|
* 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
|
* Enforcement is layered, and only the last layer is real security:
|
||||||
* request. Only the last one is real security; the first two are UX.
|
* 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 }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = await getTranslations('common');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<RequireSession>
|
||||||
<AdminSidebar />
|
<div className="flex min-h-screen">
|
||||||
<div className="min-w-0 flex-1">
|
<AdminSidebar />
|
||||||
<header className="border-ink-200 flex h-14 items-center justify-end gap-4 border-b bg-white px-6">
|
<div className="min-w-0 flex-1">
|
||||||
<LanguageSwitcher />
|
<SessionBar />
|
||||||
<span className="text-ink-500 text-xs font-medium">{t('signedOut')}</span>
|
<main>{children}</main>
|
||||||
</header>
|
</div>
|
||||||
<main>{children}</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RequireSession>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = await getTranslations('pages.roles');
|
const t = await getTranslations('pages.roles');
|
||||||
@@ -12,11 +12,13 @@ export default async function RolesPage() {
|
|||||||
const t = await getTranslations('pages.roles');
|
const t = await getTranslations('pages.roles');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold
|
<div className="space-y-6 p-8">
|
||||||
title={t('title')}
|
<header>
|
||||||
description={t('body')}
|
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
||||||
permission="role.read"
|
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
|
||||||
milestone="M2 — auth & RBAC"
|
</header>
|
||||||
/>
|
|
||||||
|
<RolesPanel />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = await getTranslations('pages.users');
|
const t = await getTranslations('pages.users');
|
||||||
@@ -12,11 +12,13 @@ export default async function UsersPage() {
|
|||||||
const t = await getTranslations('pages.users');
|
const t = await getTranslations('pages.users');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScaffold
|
<div className="space-y-6 p-8">
|
||||||
title={t('title')}
|
<header>
|
||||||
description={t('body')}
|
<h1 className="text-2xl font-bold">{t('title')}</h1>
|
||||||
permission="user.read"
|
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('body')}</p>
|
||||||
milestone="M2 — auth & RBAC"
|
</header>
|
||||||
/>
|
|
||||||
|
<UsersTable />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { getLocale, getTranslations } from 'next-intl/server';
|
|||||||
|
|
||||||
import { LOCALE_TAGS } from '@sport/types';
|
import { LOCALE_TAGS } from '@sport/types';
|
||||||
|
|
||||||
|
import { SessionProvider } from '@/features/auth/session-provider';
|
||||||
|
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
@@ -27,7 +29,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-screen antialiased">
|
<body className="min-h-screen antialiased">
|
||||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
<NextIntlClientProvider>
|
||||||
|
<SessionProvider>{children}</SessionProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import Link from 'next/link';
|
'use client';
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
|
||||||
|
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';
|
import { NAVIGATION } from '@/lib/navigation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders every section for now. Once the session carries permissions, each
|
* Navigation filtered by the signed-in operator's permissions.
|
||||||
* item is filtered with `hasPermission(actor.permissions, item.permission)` —
|
*
|
||||||
* the same catalog the API guards read, so menu and enforcement cannot drift.
|
* 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() {
|
export function AdminSidebar() {
|
||||||
const t = await getTranslations('common');
|
const t = useTranslations('common');
|
||||||
const tPages = await getTranslations('pages');
|
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 (
|
return (
|
||||||
<aside className="border-ink-200 hidden w-60 shrink-0 border-r bg-white lg:block">
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-6 p-5">
|
<nav className="space-y-6 p-5">
|
||||||
{NAVIGATION.map((section) => (
|
{sections.map((section) => (
|
||||||
<div key={section.titleKey}>
|
<div key={section.titleKey}>
|
||||||
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
|
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||||
{t(`sections.${section.titleKey}`)}
|
{t(`sections.${section.titleKey}`)}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mt-2 space-y-0.5">
|
<ul className="mt-2 space-y-0.5">
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => {
|
||||||
<li key={item.href}>
|
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
<Link
|
|
||||||
href={item.href}
|
return (
|
||||||
className="rounded-card text-ink-600 hover:bg-ink-100 hover:text-ink-950 block px-2 py-1.5 text-sm"
|
<li key={item.href}>
|
||||||
>
|
<Link
|
||||||
{tPages(`${item.labelKey}.title`)}
|
href={item.href}
|
||||||
</Link>
|
aria-current={active ? 'page' : undefined}
|
||||||
</li>
|
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>
|
</ul>
|
||||||
</div>
|
</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 { createApiClient } from '@sport/api-client';
|
||||||
|
|
||||||
import { clientEnv, getServerEnv } from './env';
|
import { getServerEnv } from './env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin's only channel to data.
|
* The admin's only channel to data.
|
||||||
*
|
*
|
||||||
* There is no Prisma client in this application and there never will be. Every
|
* 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
|
* validation and audit logging apply uniformly — a second write path is a
|
||||||
* second place for authorization to be forgotten.
|
* second place for authorization to be forgotten.
|
||||||
*/
|
*/
|
||||||
@@ -14,7 +14,76 @@ export function getServerApi() {
|
|||||||
return createApiClient({ baseUrl: getServerEnv().API_INTERNAL_URL });
|
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({
|
export const browserApi = createApiClient({
|
||||||
baseUrl: clientEnv.NEXT_PUBLIC_API_URL,
|
baseUrl: '',
|
||||||
getAccessToken: () => null, // wired to the auth store in the auth milestone
|
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": {
|
"login": {
|
||||||
"title": "Sign in",
|
"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": {
|
"pages": {
|
||||||
@@ -77,5 +89,20 @@
|
|||||||
"title": "Roles",
|
"title": "Roles",
|
||||||
"body": "Role editor: a role is a named set of permissions, editable at runtime with no deploy."
|
"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": {
|
"login": {
|
||||||
"title": "Đăng nhập",
|
"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": {
|
"pages": {
|
||||||
@@ -77,5 +89,20 @@
|
|||||||
"title": "Vai trò",
|
"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."
|
"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 limiting ----------------------------------------------------------
|
||||||
RATE_LIMIT_TTL_SECONDS=60
|
RATE_LIMIT_TTL_SECONDS=60
|
||||||
RATE_LIMIT_MAX=120
|
RATE_LIMIT_MAX=300
|
||||||
|
|
||||||
# --- Observability ----------------------------------------------------------
|
# --- Observability ----------------------------------------------------------
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"db:deploy": "prisma migrate deploy",
|
"db:deploy": "prisma migrate deploy",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"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": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"@sport/types": "workspace:*",
|
"@sport/types": "workspace:*",
|
||||||
"@sport/validation": "workspace:*",
|
"@sport/validation": "workspace:*",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "^4.6.1",
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
"@sport/config": "workspace:*",
|
"@sport/config": "workspace:*",
|
||||||
"@sport/eslint-config": "workspace:*",
|
"@sport/eslint-config": "workspace:*",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.19.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 { PrismaClient } from '@prisma/client';
|
||||||
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
|
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
|
||||||
|
|
||||||
|
import { seedDevAccounts } from './seed/accounts';
|
||||||
import { seedCatalog } from './seed/catalog';
|
import { seedCatalog } from './seed/catalog';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -144,6 +145,19 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await seedCatalog(prisma);
|
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()
|
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 { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||||
import { MediaUrlModule } from './common/media/media.module';
|
import { MediaUrlModule } from './common/media/media.module';
|
||||||
|
import { SecurityModule } from './common/security/security.module';
|
||||||
import { APP_CONFIG, AppConfigModule } from './config/app-config.module';
|
import { APP_CONFIG, AppConfigModule } from './config/app-config.module';
|
||||||
import type { AppConfig } from './config/configuration';
|
import type { AppConfig } from './config/configuration';
|
||||||
import { EventsModule } from './infrastructure/events/events.module';
|
import { EventsModule } from './infrastructure/events/events.module';
|
||||||
@@ -55,6 +56,7 @@ import { WishlistModule } from './modules/wishlist/wishlist.module';
|
|||||||
StorageModule,
|
StorageModule,
|
||||||
EventsModule,
|
EventsModule,
|
||||||
MediaUrlModule,
|
MediaUrlModule,
|
||||||
|
SecurityModule,
|
||||||
|
|
||||||
ThrottlerModule.forRootAsync({
|
ThrottlerModule.forRootAsync({
|
||||||
inject: [APP_CONFIG],
|
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(),
|
STORAGE_PUBLIC_URL: z.url(),
|
||||||
|
|
||||||
RATE_LIMIT_TTL_SECONDS: z.coerce.number().int().positive().default(60),
|
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_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
LOG_PRETTY: z.stringbool().default(false),
|
LOG_PRETTY: z.stringbool().default(false),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { Logger } from 'nestjs-pino';
|
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.
|
// First in the chain: every log line and error response carries this id.
|
||||||
app.use(requestIdMiddleware);
|
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(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
|
||||||
app.use(compression());
|
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 { APP_GUARD } from '@nestjs/core';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
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 { AccessTokenGuard } from './guards/access-token.guard';
|
||||||
import { PermissionsGuard } from './guards/permissions.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,
|
* AuthModule — owns `sessions`, and nothing else.
|
||||||
* audience separation and RBAC evaluation, wired globally.
|
|
||||||
*
|
*
|
||||||
* The *issuance* half — login, registration, refresh rotation, password reset,
|
* Accounts, roles and permissions belong to UsersModule; this module exchanges
|
||||||
* OTP — is milestone 1. Splitting it this way means every endpoint written from
|
* credentials for tokens and manages session lifetime. Password *hashing* lives
|
||||||
* here on is protected by default, before a single credential exists.
|
* in `common/security` so that both modules can use it without a cycle.
|
||||||
*
|
*
|
||||||
* Guard order matters: AccessTokenGuard must populate `request.actor` before
|
* Guard order matters: AccessTokenGuard must populate `request.actor` before
|
||||||
* PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order.
|
* PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order.
|
||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({})],
|
imports: [JwtModule.register({}), UsersModule],
|
||||||
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
AuthRepository,
|
||||||
|
TokenService,
|
||||||
|
LoginThrottleService,
|
||||||
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
||||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
],
|
],
|
||||||
exports: [JwtModule],
|
exports: [JwtModule, AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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.
|
* Public surface of UsersModule.
|
||||||
*
|
*
|
||||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
* AuthModule consumes `UsersService` for account lookup, permission resolution
|
||||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
* and login bookkeeping. The repositories, the mapper and every Prisma row type
|
||||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
* stay private.
|
||||||
*
|
|
||||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
|
||||||
*/
|
*/
|
||||||
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 { 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 plus the RBAC administration surface. AuthModule reads
|
||||||
*
|
* accounts through this module's public service; it never queries `users`
|
||||||
* Back-office identity and the RBAC administration surface. Owns the role/permission tables that AuthModule only reads through this module.
|
* itself, which keeps credential exchange and account management separable.
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
@Module({})
|
@Module({
|
||||||
|
controllers: [UsersController, RolesController],
|
||||||
|
providers: [UsersService, RolesService, UsersRepository, RolesRepository, UsersMapper],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
export class UsersModule {}
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
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
|
* Workspace packages ship TypeScript source rather than a build artefact, so
|
||||||
* Next compiles them with the app. No watch-and-rebuild step during local
|
* Next compiles them with the app. No watch-and-rebuild step during local
|
||||||
@@ -23,13 +37,35 @@ const nextConfig: NextConfig = {
|
|||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
|
|
||||||
images: {
|
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: [
|
remotePatterns: [
|
||||||
{ protocol: 'http', hostname: 'localhost', port: '9000' },
|
{ protocol: 'http', hostname: 'localhost', port: '9000', pathname: '/**', search: '' },
|
||||||
{ protocol: 'https', hostname: '**.r2.dev' },
|
{ protocol: 'https', hostname: '**.r2.dev', pathname: '/**', search: '' },
|
||||||
{ protocol: 'https', hostname: 'cdn.sport-store.local' },
|
{ protocol: 'https', hostname: 'cdn.sport-store.local', pathname: '/**', search: '' },
|
||||||
],
|
],
|
||||||
formats: ['image/avif', 'image/webp'],
|
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).
|
// Standalone output keeps the production image small (no node_modules copy).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createApiClient, type RequestOptions } from '@sport/api-client';
|
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:
|
* 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({
|
export const browserApi = createApiClient({
|
||||||
baseUrl: clientEnv.NEXT_PUBLIC_API_URL,
|
baseUrl: '',
|
||||||
getAccessToken: () => null, // wired to the auth store in the auth milestone
|
getAccessToken: () => null, // wired to the customer auth store in M8
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# ADR-0015: Frontends reach the API through their own origin
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The refresh token is delivered as an httpOnly cookie (ADR-0008) — that is what
|
||||||
|
stops an XSS from stealing the credential that mints new sessions.
|
||||||
|
|
||||||
|
Cookies are governed by `SameSite`. If the browser calls `api.example.com` from
|
||||||
|
`admin.example.com`, that is a cross-site request, and the cookie only rides
|
||||||
|
along with `SameSite=None`. `SameSite=None` requires `Secure`, which requires
|
||||||
|
HTTPS. Local development runs on plain HTTP, so the cookie would simply never be
|
||||||
|
set — presenting as a login that "succeeds" and then immediately forgets you.
|
||||||
|
|
||||||
|
The alternatives are all worse: put the refresh token in `localStorage` (readable
|
||||||
|
by any script, which defeats the entire httpOnly design), run local development
|
||||||
|
over self-signed HTTPS (friction on every machine and in CI), or accept that dev
|
||||||
|
and production authenticate differently (the class of bug you only find in
|
||||||
|
staging).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The browser always calls the API on the same origin as the page it is on.**
|
||||||
|
|
||||||
|
- Production: Nginx already routes `/api/` to the API container on the same host
|
||||||
|
— this was in the topology from milestone 0.
|
||||||
|
- Development: a Next.js `rewrites()` entry maps `/api/:path*` to
|
||||||
|
`API_INTERNAL_URL`, reproducing that topology exactly.
|
||||||
|
- `browserApi` is therefore created with `baseUrl: ''`, and `HttpClient`
|
||||||
|
resolves a relative base against `window.location.origin`.
|
||||||
|
|
||||||
|
Server-side rendering is unaffected: it calls `API_INTERNAL_URL` directly over
|
||||||
|
the internal network, because there is no cookie and no browser involved.
|
||||||
|
|
||||||
|
The cookie is then first-party, `SameSite=Lax`, `httpOnly`, `Secure` in
|
||||||
|
production, and scoped to `path=/api/v1/auth` so it is attached to two endpoints
|
||||||
|
rather than every API call.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Development and production share one authentication topology, so a cookie
|
||||||
|
problem is reproducible locally instead of appearing first in staging.
|
||||||
|
|
||||||
|
CORS effectively disappears for browser traffic — the requests are same-origin.
|
||||||
|
The API's CORS config remains for non-browser and tooling access.
|
||||||
|
|
||||||
|
Storefront and admin get separately named cookies (`sport_refresh`,
|
||||||
|
`sport_admin_refresh`). Sharing a name would mean signing into the admin
|
||||||
|
silently replaced a customer session in the same browser.
|
||||||
|
|
||||||
|
The costs, stated plainly:
|
||||||
|
|
||||||
|
- One extra network hop in development (browser → Next → API). Irrelevant
|
||||||
|
locally, and absent in production where Nginx was already the front door.
|
||||||
|
- The frontends now have a route namespace (`/api/*`) they must not use for
|
||||||
|
their own route handlers. Worth noting in review; neither app has any.
|
||||||
|
- `API_INTERNAL_URL` becomes required for the dev rewrite, not just for SSR.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Refresh token in `localStorage`.** Removes the cookie problem entirely and
|
||||||
|
removes the security property with it — any injected script can read it.
|
||||||
|
Rejected outright.
|
||||||
|
|
||||||
|
**`SameSite=None; Secure` with HTTPS in development.** Correct, but forces every
|
||||||
|
developer and CI job to trust a local certificate. Rejected as friction that
|
||||||
|
buys nothing production does not already provide.
|
||||||
|
|
||||||
|
**A dedicated auth subdomain with a parent-domain cookie.** Works in production,
|
||||||
|
but `localhost` has no usable parent domain, so development still diverges.
|
||||||
|
Rejected for the same reason as above.
|
||||||
@@ -23,6 +23,7 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
|
|||||||
| [0012](./0012-postgresql-full-text-search-before-a-dedicated-search-engine.md) | PostgreSQL full-text search before a dedicated search engine | Accepted |
|
| [0012](./0012-postgresql-full-text-search-before-a-dedicated-search-engine.md) | PostgreSQL full-text search before a dedicated search engine | Accepted |
|
||||||
| [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted |
|
| [0013](./0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md) | Content translations in typed tables, UI strings in message catalogs | Accepted |
|
||||||
| [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted |
|
| [0014](./0014-denormalised-price-projection-on-product.md) | A denormalised price/stock projection on Product | Accepted |
|
||||||
|
| [0015](./0015-frontends-reach-the-api-through-their-own-origin.md) | Frontends reach the API through their own origin | Accepted |
|
||||||
|
|
||||||
## Decisions deliberately NOT recorded yet
|
## Decisions deliberately NOT recorded yet
|
||||||
|
|
||||||
|
|||||||
+122
-34
@@ -240,7 +240,65 @@ consumer transient too, which for `PrismaService` would mean a second connection
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Internationalisation
|
## 9. Authentication and sessions
|
||||||
|
|
||||||
|
See [ADR-0008](./adr/0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md) for
|
||||||
|
the decision and [ADR-0015](./adr/0015-frontends-reach-the-api-through-their-own-origin.md) for
|
||||||
|
the transport.
|
||||||
|
|
||||||
|
### The two tokens
|
||||||
|
|
||||||
|
| | Access token | Refresh token |
|
||||||
|
| ------------ | ---------------------------------------------- | --------------------------------------- |
|
||||||
|
| Form | JWT | Opaque random bytes |
|
||||||
|
| Lifetime | 15 minutes | 30 days |
|
||||||
|
| Stored where | Client memory only | httpOnly cookie + SHA-256 in `sessions` |
|
||||||
|
| Revocable | No (stateless by design) | Yes |
|
||||||
|
| Carries | userId, audience, type, permissions, sessionId | Nothing — it is a lookup key |
|
||||||
|
|
||||||
|
The refresh token is **not** a JWT. There is nothing for a client to read in it, and because it
|
||||||
|
is checked against a row it can be revoked — which a stateless token fundamentally cannot be.
|
||||||
|
Only its hash is stored, so a database leak yields no usable credential.
|
||||||
|
|
||||||
|
### Rotation and reuse detection
|
||||||
|
|
||||||
|
```
|
||||||
|
login → session A (new family)
|
||||||
|
refresh(A) → session B, A.replacedBy=B (same family)
|
||||||
|
refresh(A) → REUSE: revoke whole family
|
||||||
|
```
|
||||||
|
|
||||||
|
A rotated token that shows up again means either it leaked or the client is broken. Both justify
|
||||||
|
killing the family, which signs out the thief _and_ the legitimate device — deliberately, because
|
||||||
|
the alternative is letting a known-compromised session continue.
|
||||||
|
|
||||||
|
### Audience separation
|
||||||
|
|
||||||
|
Every token carries an audience (`storefront` / `admin`) and each has its own login endpoint and
|
||||||
|
its own cookie name. It is enforced three times: at issuance (the user's type must match), on
|
||||||
|
every request (`AccessTokenGuard`, before permissions are read), and per controller via
|
||||||
|
`@RequireAudience`.
|
||||||
|
|
||||||
|
### Why login is deliberately uninformative
|
||||||
|
|
||||||
|
Unknown email, wrong password, suspended account and wrong audience all return the same message
|
||||||
|
and status, and the unknown-email path burns equivalent CPU so it is not measurably faster.
|
||||||
|
Anything else turns the login form into a user-enumeration oracle.
|
||||||
|
|
||||||
|
Failures are counted twice in Redis: per account (stops guessing one password from many IPs) and
|
||||||
|
per IP (stops spraying one password across many accounts). Only failures count, so a busy
|
||||||
|
legitimate user is never locked out.
|
||||||
|
|
||||||
|
### Known limitation
|
||||||
|
|
||||||
|
A permission change takes effect within one access-token lifetime, not instantly — that is the
|
||||||
|
price of stateless guards with no database read on the hot path. `CACHE_KEYS.revokedSession`
|
||||||
|
exists for an immediate denylist and is deliberately not wired up; it reintroduces exactly the
|
||||||
|
lookup the design removes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Internationalisation
|
||||||
|
|
||||||
Two languages, **two mechanisms**, split by who owns the string. Conflating them is the usual
|
Two languages, **two mechanisms**, split by who owns the string. Conflating them is the usual
|
||||||
way an i18n project ends up half-finished. See
|
way an i18n project ends up half-finished. See
|
||||||
@@ -284,7 +342,7 @@ locale-in-path would add a proxy hop and double the route tree for nothing.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Naming conventions
|
## 11. Naming conventions
|
||||||
|
|
||||||
| Thing | Convention | Example |
|
| Thing | Convention | Example |
|
||||||
| --------------------- | ------------------------- | ------------------------------ |
|
| --------------------- | ------------------------- | ------------------------------ |
|
||||||
@@ -309,7 +367,7 @@ Booleans read as assertions: `isActive`, `hasVariants`, `canRefund`. Money field
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Configuration and environment
|
## 12. Configuration and environment
|
||||||
|
|
||||||
Four `.env` files, each with a committed `.env.example`:
|
Four `.env` files, each with a committed `.env.example`:
|
||||||
|
|
||||||
@@ -336,7 +394,7 @@ Rules:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Where premature abstraction must be avoided
|
## 13. Where premature abstraction must be avoided
|
||||||
|
|
||||||
Places where the instinct to generalise should be resisted until a second real case appears:
|
Places where the instinct to generalise should be resisted until a second real case appears:
|
||||||
|
|
||||||
@@ -358,50 +416,80 @@ Places where the instinct to generalise should be resisted until a second real c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Architectural risks to prevent from day one
|
## 14. Architectural risks to prevent from day one
|
||||||
|
|
||||||
| Risk | Why it is fatal later | Prevention in place |
|
| Risk | Why it is fatal later | Prevention in place |
|
||||||
| ---------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) |
|
| Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) |
|
||||||
| Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) |
|
| Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) |
|
||||||
| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) |
|
| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) |
|
||||||
| Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin ([ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md)) |
|
| Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin ([ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md)) |
|
||||||
| Module boundary erosion | The monolith becomes unsplittable and untestable | ESLint boundary rules + `public/` barrels |
|
| Module boundary erosion | The monolith becomes unsplittable and untestable | ESLint boundary rules + `public/` barrels |
|
||||||
| Order lines joined to live catalog | Historical invoices change when prices do | Snapshot fields on order lines (milestone 5) |
|
| Order lines joined to live catalog | Historical invoices change when prices do | Snapshot fields on order lines (milestone 5) |
|
||||||
| Overselling under concurrency | Real money, real customers, real refunds | `reserved` column + transactional reservation |
|
| Overselling under concurrency | Real money, real customers, real refunds | `reserved` column + transactional reservation |
|
||||||
| Unversioned API | Cannot ship a breaking change once a mobile app exists | URI versioning from request one ([ADR-0005](./adr/0005-uri-based-api-versioning.md)) |
|
| Unversioned API | Cannot ship a breaking change once a mobile app exists | URI versioning from request one ([ADR-0005](./adr/0005-uri-based-api-versioning.md)) |
|
||||||
| Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage ([ADR-0009](./adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md)) |
|
| Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage ([ADR-0009](./adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md)) |
|
||||||
| Single-warehouse inventory | Adding a location later means migrating live stock history | `(variant, location)` keys from the start |
|
| Single-warehouse inventory | Adding a location later means migrating live stock history | `(variant, location)` keys from the start |
|
||||||
| Secrets in the repository | One leak compromises production | Validated env, generated dev secrets, `.env` gitignored |
|
| Secrets in the repository | One leak compromises production | Validated env, generated dev secrets, `.env` gitignored |
|
||||||
| No request correlation | Production incidents become guesswork | `x-request-id` end to end |
|
| No request correlation | Production incidents become guesswork | `x-request-id` end to end |
|
||||||
|
| Browser-only failures passing every check | Server rendering and curl both succeed while every click fails | Client paths must be exercised in a real browser before a milestone closes |
|
||||||
|
| `onUnauthorized` retrying the refresh call | Unbounded refresh loop hammering the API from the browser | `skipAuthRetry` on all auth endpoints plus a single-flight refresh |
|
||||||
|
| Concurrent 401s each rotating the token | The second rotation reads as token reuse and revokes the family | One shared in-flight refresh promise |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Deliberate limitations
|
## 15. Testing that actually catches things
|
||||||
|
|
||||||
|
The catalog and auth milestones both passed lint, typecheck, unit tests, a full build and
|
||||||
|
curl-based API checks — while the admin login button did nothing at all in a browser. Three
|
||||||
|
defects hid in that gap, and all three shared one property: **they only exist in a browser.**
|
||||||
|
|
||||||
|
- `globalThis.fetch` stored on an object and called as a method throws `Illegal invocation`
|
||||||
|
in a browser and works fine in Node. Server rendering and curl could not have caught it.
|
||||||
|
- `onUnauthorized` refreshing through an endpoint that was itself retryable recursed without
|
||||||
|
bound — visible only as a flood of requests in a real network panel.
|
||||||
|
- Next 16 refuses upstream images on private IPs, and reports it with the _same_ message it
|
||||||
|
uses for an unmatched remote pattern. Only the rendered page revealed it.
|
||||||
|
|
||||||
|
The rule this earns: **a milestone touching client behaviour is not done until its
|
||||||
|
interactions have been clicked in a real browser**, with the console and network panel open.
|
||||||
|
Regression tests now cover the first two (`packages/api-client/src/http-client.spec.ts`); the
|
||||||
|
third belongs in an end-to-end test when one exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Deliberate limitations
|
||||||
|
|
||||||
Stated plainly so they are choices rather than oversights.
|
Stated plainly so they are choices rather than oversights.
|
||||||
|
|
||||||
**Shipped (M0–M1)**
|
**Shipped (M0–M2)**
|
||||||
|
|
||||||
- Catalog reads: products, variants, options, categories, collections, brands, navigation —
|
- Catalog reads: products, variants, options, categories, collections, brands, navigation —
|
||||||
localised, cached, filtered and faceted.
|
localised, cached, filtered and faceted.
|
||||||
- RBAC enforcement, response envelope, structured logging, media pipeline.
|
- Storefront browsing and PDP in Vietnamese and English, with per-locale slugs and `hreflang`.
|
||||||
- Storefront browsing and PDP in Vietnamese and English.
|
- Auth: login, refresh rotation with reuse detection, audience separation, login throttling.
|
||||||
|
- RBAC enforcement end to end, plus user administration and a role viewer in the admin.
|
||||||
|
|
||||||
**Not built yet, and why**
|
**Not built yet, and why**
|
||||||
|
|
||||||
- **No token issuance.** Guards verify and enforce; login, refresh rotation and registration are
|
- **No customer-facing auth UI.** The storefront login/register endpoints exist and are tested;
|
||||||
M2. Every endpoint written from here is protected by default, before a credential exists.
|
the screens land with the account milestone (M8) where they belong.
|
||||||
- **No cart, order or payment tables.** They arrive in M5, so the migration history stays
|
- **The role editor is read-only.** Viewing which role grants what is the question operators
|
||||||
reviewable and the variant model gets proven against real reads first.
|
actually ask; editing grants is destructive and wants a confirmation flow and an audit entry.
|
||||||
- **`best_selling` and `relevance` sorts fall back to newest.** There is no order data (M5) and
|
- **A password reset does not revoke existing sessions.** Doing it properly means revoking every
|
||||||
no ranking (M6). Falling back is honest; a fake ranking would not be.
|
family for the user and belongs with the session-management screen, not bolted onto the
|
||||||
- **No sport/gender facet counts.** Those dimensions are navigated by route, not refined within a
|
endpoint. Noted in the code.
|
||||||
page, so a count would render nowhere.
|
- **No email.** Password reset, order confirmations and back-in-stock alerts all need it; Mailpit
|
||||||
- **Facet counts are computed per request.** Fine at this catalog size; the fix when it stops
|
is already running locally for when it lands.
|
||||||
being fine is a search index (ADR-0012), not a bigger query.
|
- **No cart, order or payment tables.** M5, so the migration history stays reviewable.
|
||||||
|
- **`best_selling` and `relevance` sorts fall back to newest.** No order data (M5), no ranking
|
||||||
|
(M6). Falling back is honest; a fake ranking would not be.
|
||||||
|
- **No sport/gender facet counts.** Navigated by route, not refined in-page, so a count would
|
||||||
|
render nowhere.
|
||||||
|
- **Facet counts are computed per request.** Fine at this catalog size; the fix when it is not is
|
||||||
|
a search index (ADR-0012), not a bigger query.
|
||||||
- **The event bus is in-process and lossy.** Anything that must not be lost stays in the same
|
- **The event bus is in-process and lossy.** Anything that must not be lost stays in the same
|
||||||
database transaction as its cause.
|
transaction as its cause.
|
||||||
- **No observability beyond logs.** Traces and metrics are worth adding once there is production
|
- **No observability beyond logs.** Traces and metrics are worth adding once there is production
|
||||||
traffic to explain.
|
traffic to explain.
|
||||||
- **No CDN, TLS or WAF config.** That belongs to the deployment repository.
|
- **No CDN, TLS or WAF config.** That belongs to the deployment repository.
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@
|
|||||||
"db:deploy": "pnpm --filter @sport/api run db:deploy",
|
"db:deploy": "pnpm --filter @sport/api run db:deploy",
|
||||||
"db:studio": "pnpm --filter @sport/api run db:studio",
|
"db:studio": "pnpm --filter @sport/api run db:studio",
|
||||||
"db:seed": "pnpm --filter @sport/api run db:seed",
|
"db:seed": "pnpm --filter @sport/api run db:seed",
|
||||||
"db:reset": "pnpm --filter @sport/api run db:reset"
|
"db:reset": "pnpm --filter @sport/api run db:reset",
|
||||||
|
"db:create-admin": "pnpm --filter @sport/api run create-admin"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sport/eslint-config": "workspace:*",
|
"@sport/eslint-config": "workspace:*",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||||
"clean": "rm -rf dist .turbo *.tsbuildinfo",
|
"clean": "rm -rf dist .turbo *.tsbuildinfo",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "jest --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sport/types": "workspace:*"
|
"@sport/types": "workspace:*"
|
||||||
@@ -29,6 +30,17 @@
|
|||||||
"@sport/eslint-config": "workspace:*",
|
"@sport/eslint-config": "workspace:*",
|
||||||
"@types/node": "^22.19.0",
|
"@types/node": "^22.19.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:",
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
|
"@types/jest": "^30.0.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "ts-jest",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"roots": [
|
||||||
|
"<rootDir>/src"
|
||||||
|
],
|
||||||
|
"testRegex": ".*\\.spec\\.ts$"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { HttpClient, type HttpClientOptions } from './http-client';
|
import { HttpClient, type HttpClientOptions } from './http-client';
|
||||||
|
import { createAdminResource, type AdminResource } from './resources/admin';
|
||||||
|
import { createAuthResource, type AuthResource } from './resources/auth';
|
||||||
import { createCatalogResource, type CatalogResource } from './resources/catalog';
|
import { createCatalogResource, type CatalogResource } from './resources/catalog';
|
||||||
import { createHealthResource, type HealthResource } from './resources/health';
|
import { createHealthResource, type HealthResource } from './resources/health';
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ export interface ApiClient {
|
|||||||
readonly http: HttpClient;
|
readonly http: HttpClient;
|
||||||
readonly health: HealthResource;
|
readonly health: HealthResource;
|
||||||
readonly catalog: CatalogResource;
|
readonly catalog: CatalogResource;
|
||||||
|
readonly auth: AuthResource;
|
||||||
|
readonly admin: AdminResource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApiClient(options: HttpClientOptions): ApiClient {
|
export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||||
@@ -21,5 +25,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
|
|||||||
http,
|
http,
|
||||||
health: createHealthResource(http),
|
health: createHealthResource(http),
|
||||||
catalog: createCatalogResource(http),
|
catalog: createCatalogResource(http),
|
||||||
|
auth: createAuthResource(http),
|
||||||
|
admin: createAdminResource(http),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { HttpClient } from './http-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for two bugs that only manifested in a browser, and so
|
||||||
|
* survived every server-side and curl-based check.
|
||||||
|
*/
|
||||||
|
describe('HttpClient', () => {
|
||||||
|
const okResponse = () =>
|
||||||
|
new Response(JSON.stringify({ success: true, data: { ok: true }, meta: {} }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls the default fetch with the global receiver, not the client instance', async () => {
|
||||||
|
// Browsers require `fetch` to be invoked with Window as `this`. Storing
|
||||||
|
// `globalThis.fetch` on the instance and calling `this.fetchImpl(...)`
|
||||||
|
// passes the HttpClient as the receiver and throws "Illegal invocation" —
|
||||||
|
// in the browser only. Node does not care, which is precisely why every
|
||||||
|
// server-side check and every curl passed while the browser was broken.
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
const receivers: unknown[] = [];
|
||||||
|
|
||||||
|
globalThis.fetch = function (this: unknown) {
|
||||||
|
// Pushed rather than assigned to a local: capturing the receiver is the
|
||||||
|
// point of the test, and a plain alias trips `no-this-alias`.
|
||||||
|
receivers.push(this);
|
||||||
|
return Promise.resolve(okResponse());
|
||||||
|
} as unknown as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// No fetchImpl — exercise the default path, which is the one that broke.
|
||||||
|
const client = new HttpClient({ baseUrl: 'http://api.test' });
|
||||||
|
await client.get('/thing');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(receivers).toHaveLength(1);
|
||||||
|
expect(receivers[0]).toBe(globalThis);
|
||||||
|
expect(receivers[0]).not.toBeInstanceOf(HttpClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry a request that opted out, so refresh cannot recurse', async () => {
|
||||||
|
// `onUnauthorized` refreshes by calling the refresh endpoint. If that call
|
||||||
|
// is itself retryable, its own 401 triggers another refresh — an unbounded
|
||||||
|
// loop that hammers the API from the browser.
|
||||||
|
let calls = 0;
|
||||||
|
let refreshes = 0;
|
||||||
|
|
||||||
|
const fetchImpl = (() => {
|
||||||
|
calls += 1;
|
||||||
|
return Promise.resolve(new Response('{}', { status: 401 }));
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new HttpClient({
|
||||||
|
baseUrl: 'http://api.test',
|
||||||
|
fetchImpl,
|
||||||
|
onUnauthorized: () => {
|
||||||
|
refreshes += 1;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.post('/auth/refresh', undefined, { skipAuthRetry: true }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(refreshes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still offers one retry for ordinary requests', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
|
||||||
|
const fetchImpl = (() => {
|
||||||
|
calls += 1;
|
||||||
|
return Promise.resolve(calls === 1 ? new Response('{}', { status: 401 }) : okResponse());
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new HttpClient({
|
||||||
|
baseUrl: 'http://api.test',
|
||||||
|
fetchImpl,
|
||||||
|
onUnauthorized: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.get('/protected')).resolves.toEqual({ ok: true });
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,15 @@ import { API_ERROR_CODES, type ApiResponse } from '@sport/types';
|
|||||||
import { ApiClientError } from './errors';
|
import { ApiClientError } from './errors';
|
||||||
|
|
||||||
export interface HttpClientOptions {
|
export interface HttpClientOptions {
|
||||||
/** Origin only, e.g. `http://localhost:4000`. The version prefix is added here. */
|
/**
|
||||||
|
* Origin, e.g. `http://localhost:4000` — or an empty string to call the
|
||||||
|
* current page's own origin.
|
||||||
|
*
|
||||||
|
* Same-origin is what the browser client uses: the refresh cookie is
|
||||||
|
* `SameSite=Lax` and therefore only sent first-party, so requests must go
|
||||||
|
* through the app's own host (Nginx in production, a Next.js rewrite in
|
||||||
|
* development) rather than directly to the API host.
|
||||||
|
*/
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
/** Defaults to `v1`. */
|
/** Defaults to `v1`. */
|
||||||
apiVersion?: string;
|
apiVersion?: string;
|
||||||
@@ -13,7 +21,13 @@ export interface HttpClientOptions {
|
|||||||
onUnauthorized?: () => boolean | Promise<boolean>;
|
onUnauthorized?: () => boolean | Promise<boolean>;
|
||||||
defaultHeaders?: Record<string, string>;
|
defaultHeaders?: Record<string, string>;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/** Injectable for tests and for runtimes with a patched fetch (Next.js). */
|
/**
|
||||||
|
* Injectable for tests and for runtimes with a patched fetch.
|
||||||
|
*
|
||||||
|
* Must be independently callable — pass a mock, or `window.fetch.bind(window)`.
|
||||||
|
* A bare `window.fetch` reference throws "Illegal invocation" in the browser
|
||||||
|
* because it loses its `Window` receiver.
|
||||||
|
*/
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +40,15 @@ export interface RequestOptions {
|
|||||||
cache?: RequestCache;
|
cache?: RequestCache;
|
||||||
/** Send cookies (used by the refresh-token flow). */
|
/** Send cookies (used by the refresh-token flow). */
|
||||||
credentials?: RequestCredentials;
|
credentials?: RequestCredentials;
|
||||||
|
/**
|
||||||
|
* Opts this request out of the automatic re-authentication retry.
|
||||||
|
*
|
||||||
|
* MANDATORY on the auth endpoints themselves. `onUnauthorized` refreshes by
|
||||||
|
* calling `/auth/refresh`; if that call is itself eligible for the retry,
|
||||||
|
* its own 401 triggers another refresh, which 401s, which triggers another —
|
||||||
|
* an unbounded loop that hammers the API from the browser. Ask how I know.
|
||||||
|
*/
|
||||||
|
skipAuthRetry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||||
@@ -42,7 +65,17 @@ export class HttpClient {
|
|||||||
this.options = options;
|
this.options = options;
|
||||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||||
this.apiVersion = options.apiVersion ?? 'v1';
|
this.apiVersion = options.apiVersion ?? 'v1';
|
||||||
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
/**
|
||||||
|
* Wrapped, never stored bare.
|
||||||
|
*
|
||||||
|
* `globalThis.fetch` must be invoked with `Window` as its receiver in the
|
||||||
|
* browser. Assigning it to an instance property and calling
|
||||||
|
* `this.fetchImpl(...)` invokes it with the HttpClient as `this`, which
|
||||||
|
* throws "Illegal invocation" — in the browser only. Node does not care,
|
||||||
|
* so server-side rendering and curl both worked while every click in the
|
||||||
|
* browser silently failed as a network error.
|
||||||
|
*/
|
||||||
|
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T>(path: string, options?: RequestOptions): Promise<T> {
|
get<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||||
@@ -106,7 +139,12 @@ export class HttpClient {
|
|||||||
throw ApiClientError.network(cause);
|
throw ApiClientError.network(cause);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401 && !isRetry && this.options.onUnauthorized) {
|
if (
|
||||||
|
response.status === 401 &&
|
||||||
|
!isRetry &&
|
||||||
|
!options?.skipAuthRetry &&
|
||||||
|
this.options.onUnauthorized
|
||||||
|
) {
|
||||||
const shouldRetry = await this.options.onUnauthorized();
|
const shouldRetry = await this.options.onUnauthorized();
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
return this.request<T>(method, path, body, options, true);
|
return this.request<T>(method, path, body, options, true);
|
||||||
@@ -122,6 +160,17 @@ export class HttpClient {
|
|||||||
try {
|
try {
|
||||||
payload = (await response.json()) as ApiResponse<T>;
|
payload = (await response.json()) as ApiResponse<T>;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
/**
|
||||||
|
* A non-JSON body on a 5xx means the request never reached the API —
|
||||||
|
* a reverse proxy or dev rewrite answered with an HTML error page while
|
||||||
|
* the upstream was down or restarting. Reporting that as "unreadable
|
||||||
|
* response" sends people hunting for a serialisation bug; it is an
|
||||||
|
* availability problem, and the offline message says so.
|
||||||
|
*/
|
||||||
|
if (response.status >= 500) {
|
||||||
|
throw ApiClientError.network(cause);
|
||||||
|
}
|
||||||
|
|
||||||
throw new ApiClientError({
|
throw new ApiClientError({
|
||||||
code: API_ERROR_CODES.INTERNAL_ERROR,
|
code: API_ERROR_CODES.INTERNAL_ERROR,
|
||||||
message: 'The server returned an unreadable response.',
|
message: 'The server returned an unreadable response.',
|
||||||
@@ -140,7 +189,14 @@ export class HttpClient {
|
|||||||
|
|
||||||
private buildUrl(path: string, query?: RequestOptions['query']): string {
|
private buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||||
const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`);
|
const url = new URL(
|
||||||
|
`${this.baseUrl}/api/${this.apiVersion}${normalized}`,
|
||||||
|
// Only used when baseUrl is relative. On the server a relative baseUrl is
|
||||||
|
// a configuration error, and `new URL` will say so loudly.
|
||||||
|
this.baseUrl.startsWith('http')
|
||||||
|
? undefined
|
||||||
|
: (globalThis as { location?: { origin: string } }).location?.origin,
|
||||||
|
);
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(query ?? {})) {
|
for (const [key, value] of Object.entries(query ?? {})) {
|
||||||
if (value === undefined || value === null || value === '') continue;
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
|||||||
@@ -16,4 +16,12 @@ export { HttpClient } from './http-client';
|
|||||||
export type { HttpClientOptions, RequestOptions } from './http-client';
|
export type { HttpClientOptions, RequestOptions } from './http-client';
|
||||||
export { createApiClient } from './create-client';
|
export { createApiClient } from './create-client';
|
||||||
export type { CatalogResource, ProductListQuery } from './resources/catalog';
|
export type { CatalogResource, ProductListQuery } from './resources/catalog';
|
||||||
|
export type { AuthResource, LoginCredentials } from './resources/auth';
|
||||||
|
export type {
|
||||||
|
AdminResource,
|
||||||
|
CreateUserPayload,
|
||||||
|
RolePayload,
|
||||||
|
UpdateUserPayload,
|
||||||
|
UserListParams,
|
||||||
|
} from './resources/admin';
|
||||||
export type { ApiClient } from './create-client';
|
export type { ApiClient } from './create-client';
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { OffsetPaginated, PermissionGroup, RoleDetail, UserSummary } from '@sport/types';
|
||||||
|
|
||||||
|
import type { HttpClient } from '../http-client';
|
||||||
|
|
||||||
|
export interface UserListParams {
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
q?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserPayload {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone?: string;
|
||||||
|
type: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
|
||||||
|
roleIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserPayload {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone?: string | null;
|
||||||
|
status?: 'ACTIVE' | 'INVITED' | 'SUSPENDED';
|
||||||
|
type?: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
|
||||||
|
roleIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RolePayload {
|
||||||
|
key?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back-office administration. Every call requires an admin-audience token. */
|
||||||
|
export interface AdminResource {
|
||||||
|
listUsers(params?: UserListParams): Promise<OffsetPaginated<UserSummary>>;
|
||||||
|
getUser(id: string): Promise<UserSummary>;
|
||||||
|
createUser(payload: CreateUserPayload): Promise<UserSummary>;
|
||||||
|
updateUser(id: string, payload: UpdateUserPayload): Promise<UserSummary>;
|
||||||
|
resetUserPassword(id: string, password: string): Promise<{ ok: true }>;
|
||||||
|
|
||||||
|
listRoles(): Promise<RoleDetail[]>;
|
||||||
|
getRole(id: string): Promise<RoleDetail>;
|
||||||
|
listPermissions(): Promise<PermissionGroup[]>;
|
||||||
|
createRole(
|
||||||
|
payload: Required<Pick<RolePayload, 'key' | 'name'>> & RolePayload,
|
||||||
|
): Promise<RoleDetail>;
|
||||||
|
updateRole(id: string, payload: RolePayload): Promise<RoleDetail>;
|
||||||
|
deleteRole(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminResource(http: HttpClient): AdminResource {
|
||||||
|
// Administration data is never cached: an operator editing permissions must
|
||||||
|
// see the result of their own change immediately.
|
||||||
|
const uncached = { cache: 'no-store' } as const;
|
||||||
|
|
||||||
|
return {
|
||||||
|
listUsers: (params = {}) =>
|
||||||
|
http.get<OffsetPaginated<UserSummary>>('/admin/users', { ...uncached, query: { ...params } }),
|
||||||
|
getUser: (id) => http.get<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, uncached),
|
||||||
|
createUser: (payload) => http.post<UserSummary>('/admin/users', payload, uncached),
|
||||||
|
updateUser: (id, payload) =>
|
||||||
|
http.patch<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, payload, uncached),
|
||||||
|
resetUserPassword: (id, password) =>
|
||||||
|
http.post<{ ok: true }>(
|
||||||
|
`/admin/users/${encodeURIComponent(id)}/password`,
|
||||||
|
{ password },
|
||||||
|
uncached,
|
||||||
|
),
|
||||||
|
|
||||||
|
listRoles: () => http.get<RoleDetail[]>('/admin/roles', uncached),
|
||||||
|
getRole: (id) => http.get<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
|
||||||
|
listPermissions: () => http.get<PermissionGroup[]>('/admin/roles/permissions', uncached),
|
||||||
|
createRole: (payload) => http.post<RoleDetail>('/admin/roles', payload, uncached),
|
||||||
|
updateRole: (id, payload) =>
|
||||||
|
http.patch<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, payload, uncached),
|
||||||
|
deleteRole: (id) => http.delete<void>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { CurrentUser, LoginResult, RefreshResult, SessionSummary } from '@sport/types';
|
||||||
|
|
||||||
|
import type { HttpClient } from '../http-client';
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storefront and admin have separate endpoints because they issue separate
|
||||||
|
* cookies for separate audiences. Exposing them as one method with a flag would
|
||||||
|
* make it far too easy to point a customer credential at the admin surface.
|
||||||
|
*/
|
||||||
|
export interface AuthResource {
|
||||||
|
login(credentials: LoginCredentials): Promise<LoginResult>;
|
||||||
|
adminLogin(credentials: LoginCredentials): Promise<LoginResult>;
|
||||||
|
refresh(): Promise<RefreshResult>;
|
||||||
|
adminRefresh(): Promise<RefreshResult>;
|
||||||
|
logout(): Promise<void>;
|
||||||
|
adminLogout(): Promise<void>;
|
||||||
|
me(): Promise<CurrentUser>;
|
||||||
|
sessions(): Promise<SessionSummary[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthResource(http: HttpClient): AuthResource {
|
||||||
|
/**
|
||||||
|
* `cache: 'no-store'` on every call. An authentication response must never be
|
||||||
|
* served from a cache — not Next's data cache, not a CDN, not the browser's.
|
||||||
|
*/
|
||||||
|
const uncached = {
|
||||||
|
cache: 'no-store',
|
||||||
|
/**
|
||||||
|
* Every auth endpoint opts out of the 401 retry. Refresh is the mechanism
|
||||||
|
* the retry *uses*, so letting it retry itself recurses without bound;
|
||||||
|
* login and logout have nothing to re-authenticate with either.
|
||||||
|
*/
|
||||||
|
skipAuthRetry: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: (credentials) => http.post<LoginResult>('/auth/login', credentials, uncached),
|
||||||
|
adminLogin: (credentials) => http.post<LoginResult>('/auth/admin/login', credentials, uncached),
|
||||||
|
refresh: () => http.post<RefreshResult>('/auth/refresh', undefined, uncached),
|
||||||
|
adminRefresh: () => http.post<RefreshResult>('/auth/admin/refresh', undefined, uncached),
|
||||||
|
logout: () => http.post<void>('/auth/logout', undefined, uncached),
|
||||||
|
adminLogout: () => http.post<void>('/auth/admin/logout', undefined, uncached),
|
||||||
|
me: () => http.get<CurrentUser>('/auth/me', uncached),
|
||||||
|
sessions: () => http.get<SessionSummary[]>('/auth/sessions', uncached),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Id, IsoDateTime, Nullable } from '../primitives';
|
||||||
|
|
||||||
|
import type { UserType } from './actors';
|
||||||
|
import type { Permission } from './permissions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `/auth/me` payload — everything a frontend needs to render a session.
|
||||||
|
*
|
||||||
|
* Note what is absent: no password hash, no session id, no refresh token, no
|
||||||
|
* internal flags. This is the whole of what a client is allowed to know about
|
||||||
|
* itself, and it is assembled explicitly rather than by spreading a database
|
||||||
|
* row, so a new column can never leak by accident.
|
||||||
|
*/
|
||||||
|
export interface CurrentUser {
|
||||||
|
readonly id: Id;
|
||||||
|
readonly email: string;
|
||||||
|
readonly type: UserType;
|
||||||
|
readonly firstName: Nullable<string>;
|
||||||
|
readonly lastName: Nullable<string>;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly avatarUrl: Nullable<string>;
|
||||||
|
readonly roles: readonly string[];
|
||||||
|
readonly permissions: readonly Permission[];
|
||||||
|
readonly lastLoginAt: Nullable<IsoDateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login response.
|
||||||
|
*
|
||||||
|
* The access token is returned in the body because the client holds it in
|
||||||
|
* memory. The refresh token is NOT here — it is set as an httpOnly cookie the
|
||||||
|
* page's JavaScript cannot read, which is the entire point: an XSS can steal
|
||||||
|
* whatever is in memory, but it cannot steal the credential that mints new
|
||||||
|
* sessions.
|
||||||
|
*/
|
||||||
|
export interface LoginResult {
|
||||||
|
readonly user: CurrentUser;
|
||||||
|
readonly accessToken: string;
|
||||||
|
readonly accessTokenExpiresAt: IsoDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returned by the refresh endpoint; the rotated cookie rides along with it. */
|
||||||
|
export interface RefreshResult {
|
||||||
|
readonly accessToken: string;
|
||||||
|
readonly accessTokenExpiresAt: IsoDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One signed-in device. Surfaced so a user can review and revoke sessions. */
|
||||||
|
export interface SessionSummary {
|
||||||
|
readonly id: Id;
|
||||||
|
readonly userAgent: Nullable<string>;
|
||||||
|
readonly ipAddress: Nullable<string>;
|
||||||
|
readonly createdAt: IsoDateTime;
|
||||||
|
readonly expiresAt: IsoDateTime;
|
||||||
|
readonly isCurrent: boolean;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export * from './api/pagination';
|
|||||||
export * from './auth/actors';
|
export * from './auth/actors';
|
||||||
export * from './auth/permissions';
|
export * from './auth/permissions';
|
||||||
export * from './auth/tokens';
|
export * from './auth/tokens';
|
||||||
|
export * from './auth/session';
|
||||||
|
export * from './users/user';
|
||||||
export * from './catalog/product';
|
export * from './catalog/product';
|
||||||
export * from './catalog/variant';
|
export * from './catalog/variant';
|
||||||
export * from './catalog/taxonomy';
|
export * from './catalog/taxonomy';
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { UserType } from '../auth/actors';
|
||||||
|
import type { Permission } from '../auth/permissions';
|
||||||
|
import type { Id, IsoDateTime, Nullable } from '../primitives';
|
||||||
|
|
||||||
|
export const USER_STATUSES = {
|
||||||
|
ACTIVE: 'ACTIVE',
|
||||||
|
INVITED: 'INVITED',
|
||||||
|
SUSPENDED: 'SUSPENDED',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserStatus = (typeof USER_STATUSES)[keyof typeof USER_STATUSES];
|
||||||
|
|
||||||
|
/** Row shape for the back-office user table. */
|
||||||
|
export interface UserSummary {
|
||||||
|
readonly id: Id;
|
||||||
|
readonly email: string;
|
||||||
|
readonly type: UserType;
|
||||||
|
readonly status: UserStatus;
|
||||||
|
readonly firstName: Nullable<string>;
|
||||||
|
readonly lastName: Nullable<string>;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly roles: readonly RoleSummary[];
|
||||||
|
readonly lastLoginAt: Nullable<IsoDateTime>;
|
||||||
|
readonly createdAt: IsoDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleSummary {
|
||||||
|
readonly id: Id;
|
||||||
|
readonly key: string;
|
||||||
|
readonly name: string;
|
||||||
|
/** System roles are seeded from code and cannot be deleted or renamed. */
|
||||||
|
readonly isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleDetail extends RoleSummary {
|
||||||
|
readonly description: Nullable<string>;
|
||||||
|
readonly permissions: readonly Permission[];
|
||||||
|
readonly userCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permission catalog, grouped by resource for the role editor.
|
||||||
|
*
|
||||||
|
* Served from the API rather than read from @sport/types in the browser so the
|
||||||
|
* admin always shows exactly what the running backend enforces — a deploy skew
|
||||||
|
* shows up as a missing checkbox, not as a silently ineffective grant.
|
||||||
|
*/
|
||||||
|
export interface PermissionGroup {
|
||||||
|
readonly resource: string;
|
||||||
|
readonly permissions: readonly PermissionInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionInfo {
|
||||||
|
readonly key: Permission;
|
||||||
|
readonly resource: string;
|
||||||
|
readonly action: string;
|
||||||
|
}
|
||||||
@@ -28,5 +28,12 @@ export const registerSchema = z.object({
|
|||||||
acceptsMarketing: z.boolean().default(false),
|
acceptsMarketing: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The refresh token travels as an httpOnly cookie, never in a body — so this
|
||||||
|
* schema is deliberately empty. It exists to document that the endpoint takes
|
||||||
|
* no client-supplied input, which is what makes it safe to expose unauthenticated.
|
||||||
|
*/
|
||||||
|
export const refreshSchema = z.object({});
|
||||||
|
|
||||||
export type LoginInput = z.infer<typeof loginSchema>;
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export * from './common';
|
|||||||
export * from './pagination';
|
export * from './pagination';
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './catalog';
|
export * from './catalog';
|
||||||
|
export * from './users';
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { passwordSchema } from './auth';
|
||||||
|
import { anyIdSchema, emailSchema, phoneSchema } from './common';
|
||||||
|
import { offsetPageQuerySchema } from './pagination';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back-office user and role management.
|
||||||
|
*
|
||||||
|
* Note what is *not* here: no `permissions` array on a user. Permissions are
|
||||||
|
* granted only through roles (ADR-0007). Allowing per-user overrides would make
|
||||||
|
* "who can refund an order?" unanswerable without inspecting every account.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const userTypeSchema = z.enum(['CUSTOMER', 'STAFF', 'ADMIN', 'SUPER_ADMIN']);
|
||||||
|
export const userStatusSchema = z.enum(['ACTIVE', 'INVITED', 'SUSPENDED']);
|
||||||
|
|
||||||
|
/** Back-office accounts only — customers are created by registration. */
|
||||||
|
export const backOfficeUserTypeSchema = z.enum(['STAFF', 'ADMIN', 'SUPER_ADMIN']);
|
||||||
|
|
||||||
|
export const userListQuerySchema = offsetPageQuerySchema.extend({
|
||||||
|
q: z.string().trim().max(120).optional(),
|
||||||
|
type: userTypeSchema.optional(),
|
||||||
|
status: userStatusSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
firstName: z.string().trim().min(1).max(80),
|
||||||
|
lastName: z.string().trim().min(1).max(80),
|
||||||
|
phone: phoneSchema.optional(),
|
||||||
|
type: backOfficeUserTypeSchema,
|
||||||
|
roleIds: z.array(anyIdSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
firstName: z.string().trim().min(1).max(80).optional(),
|
||||||
|
lastName: z.string().trim().min(1).max(80).optional(),
|
||||||
|
phone: phoneSchema.nullish(),
|
||||||
|
status: userStatusSchema.optional(),
|
||||||
|
type: backOfficeUserTypeSchema.optional(),
|
||||||
|
roleIds: z.array(anyIdSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** An operator resetting someone else's password — no current password needed. */
|
||||||
|
export const resetUserPasswordSchema = z.object({
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A user changing their own password — proves possession of the current one. */
|
||||||
|
export const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const roleKeySchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2)
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-z][a-z0-9_]*$/, 'Use lowercase letters, digits and underscores');
|
||||||
|
|
||||||
|
export const createRoleSchema = z.object({
|
||||||
|
key: roleKeySchema,
|
||||||
|
name: z.string().trim().min(2).max(120),
|
||||||
|
description: z.string().trim().max(500).nullish(),
|
||||||
|
permissions: z.array(z.string().max(64)).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateRoleSchema = z.object({
|
||||||
|
name: z.string().trim().min(2).max(120).optional(),
|
||||||
|
description: z.string().trim().max(500).nullish(),
|
||||||
|
permissions: z.array(z.string().max(64)).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserListQuery = z.output<typeof userListQuerySchema>;
|
||||||
|
export type CreateUserInput = z.output<typeof createUserSchema>;
|
||||||
|
export type UpdateUserInput = z.output<typeof updateUserSchema>;
|
||||||
|
export type CreateRoleInput = z.output<typeof createRoleSchema>;
|
||||||
|
export type UpdateRoleInput = z.output<typeof updateRoleSchema>;
|
||||||
|
export type ChangePasswordInput = z.output<typeof changePasswordSchema>;
|
||||||
Generated
+38
@@ -162,6 +162,9 @@ importers:
|
|||||||
compression:
|
compression:
|
||||||
specifier: ^1.8.1
|
specifier: ^1.8.1
|
||||||
version: 1.8.1
|
version: 1.8.1
|
||||||
|
cookie-parser:
|
||||||
|
specifier: ^1.4.7
|
||||||
|
version: 1.4.7
|
||||||
helmet:
|
helmet:
|
||||||
specifier: ^8.1.0
|
specifier: ^8.1.0
|
||||||
version: 8.3.0
|
version: 8.3.0
|
||||||
@@ -208,6 +211,9 @@ importers:
|
|||||||
'@types/compression':
|
'@types/compression':
|
||||||
specifier: ^1.8.1
|
specifier: ^1.8.1
|
||||||
version: 1.8.1
|
version: 1.8.1
|
||||||
|
'@types/cookie-parser':
|
||||||
|
specifier: ^1.4.10
|
||||||
|
version: 1.4.10(@types/express@5.0.6)
|
||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.3
|
specifier: ^5.0.3
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
@@ -321,12 +327,21 @@ importers:
|
|||||||
'@sport/eslint-config':
|
'@sport/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../eslint-config
|
version: link:../eslint-config
|
||||||
|
'@types/jest':
|
||||||
|
specifier: ^30.0.0
|
||||||
|
version: 30.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.19.0
|
specifier: ^22.19.0
|
||||||
version: 22.20.1
|
version: 22.20.1
|
||||||
eslint:
|
eslint:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 9.39.5(jiti@2.7.0)
|
version: 9.39.5(jiti@2.7.0)
|
||||||
|
jest:
|
||||||
|
specifier: ^30.2.0
|
||||||
|
version: 30.4.2(@types/node@22.20.1)
|
||||||
|
ts-jest:
|
||||||
|
specifier: ^29.4.6
|
||||||
|
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@30.4.2(@types/node@22.20.1))(typescript@5.9.3)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -2054,6 +2069,11 @@ packages:
|
|||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
|
|
||||||
|
'@types/cookie-parser@1.4.10':
|
||||||
|
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/express': '*'
|
||||||
|
|
||||||
'@types/cookiejar@2.1.5':
|
'@types/cookiejar@2.1.5':
|
||||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||||
|
|
||||||
@@ -2819,6 +2839,13 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
cookie-parser@1.4.7:
|
||||||
|
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
cookie-signature@1.0.6:
|
||||||
|
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||||
|
|
||||||
cookie-signature@1.2.2:
|
cookie-signature@1.2.2:
|
||||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||||
engines: {node: '>=6.6.0'}
|
engines: {node: '>=6.6.0'}
|
||||||
@@ -6940,6 +6967,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.20.1
|
'@types/node': 22.20.1
|
||||||
|
|
||||||
|
'@types/cookie-parser@1.4.10(@types/express@5.0.6)':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.6
|
||||||
|
|
||||||
'@types/cookiejar@2.1.5': {}
|
'@types/cookiejar@2.1.5': {}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
@@ -7756,6 +7787,13 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
cookie-parser@1.4.7:
|
||||||
|
dependencies:
|
||||||
|
cookie: 0.7.2
|
||||||
|
cookie-signature: 1.0.6
|
||||||
|
|
||||||
|
cookie-signature@1.0.6: {}
|
||||||
|
|
||||||
cookie-signature@1.2.2: {}
|
cookie-signature@1.2.2: {}
|
||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user