From 5386bc51d1059c76ac3213868c0159fc3cfc91b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=B4ng=20=C4=90=E1=BB=A9c=20Huy?= Date: Wed, 12 Aug 2026 09:12:30 +0700 Subject: [PATCH] Stage M2 --- README.md | 65 ++-- apps/admin/next.config.ts | 38 ++- apps/admin/src/app/(auth)/login/page.tsx | 12 +- apps/admin/src/app/(dashboard)/layout.tsx | 34 +- .../app/(dashboard)/settings/roles/page.tsx | 16 +- .../app/(dashboard)/settings/users/page.tsx | 16 +- apps/admin/src/app/layout.tsx | 6 +- .../src/components/layout/admin-sidebar.tsx | 68 ++-- apps/admin/src/features/auth/login-form.tsx | 105 ++++++ .../src/features/auth/require-session.tsx | 63 ++++ apps/admin/src/features/auth/session-bar.tsx | 44 +++ .../src/features/auth/session-provider.tsx | 127 ++++++++ .../src/features/settings/roles-panel.tsx | 143 +++++++++ .../src/features/settings/users-table.tsx | 107 +++++++ apps/admin/src/lib/api.ts | 77 ++++- apps/admin/src/messages/en.json | 29 +- apps/admin/src/messages/vi.json | 29 +- apps/api/.env.example | 2 +- apps/api/package.json | 5 +- apps/api/prisma/create-admin.ts | 83 +++++ apps/api/prisma/seed.ts | 14 + apps/api/prisma/seed/accounts.ts | 167 ++++++++++ apps/api/src/app.module.ts | 2 + .../src/common/security/password.service.ts | 154 +++++++++ apps/api/src/common/security/password.spec.ts | 66 ++++ .../src/common/security/security.module.ts | 19 ++ apps/api/src/config/env.schema.ts | 12 +- apps/api/src/main.ts | 4 + apps/api/src/modules/auth/auth.controller.ts | 194 ++++++++++++ apps/api/src/modules/auth/auth.module.ts | 25 +- apps/api/src/modules/auth/auth.repository.ts | 148 +++++++++ apps/api/src/modules/auth/auth.service.ts | 298 ++++++++++++++++++ .../modules/auth/login-throttle.service.ts | 84 +++++ apps/api/src/modules/auth/public/index.ts | 13 + apps/api/src/modules/auth/refresh-cookie.ts | 78 +++++ apps/api/src/modules/auth/token.service.ts | 127 ++++++++ apps/api/src/modules/users/public/index.ts | 11 +- .../api/src/modules/users/roles.controller.ts | 88 ++++++ .../api/src/modules/users/roles.repository.ts | 120 +++++++ apps/api/src/modules/users/roles.service.ts | 120 +++++++ .../api/src/modules/users/users.controller.ts | 111 +++++++ apps/api/src/modules/users/users.mapper.ts | 112 +++++++ apps/api/src/modules/users/users.module.ts | 31 +- .../api/src/modules/users/users.repository.ts | 152 +++++++++ apps/api/src/modules/users/users.service.ts | 142 +++++++++ apps/storefront/next.config.ts | 44 ++- apps/storefront/src/lib/api.ts | 18 +- ...-reach-the-api-through-their-own-origin.md | 73 +++++ docs/adr/README.md | 1 + docs/architecture.md | 156 +++++++-- package.json | 3 +- packages/api-client/package.json | 16 +- packages/api-client/src/create-client.ts | 6 + packages/api-client/src/http-client.spec.ts | 89 ++++++ packages/api-client/src/http-client.ts | 66 +++- packages/api-client/src/index.ts | 8 + packages/api-client/src/resources/admin.ts | 84 +++++ packages/api-client/src/resources/auth.ts | 51 +++ packages/types/src/auth/session.ts | 56 ++++ packages/types/src/index.ts | 2 + packages/types/src/users/user.ts | 57 ++++ packages/validation/src/auth.ts | 7 + packages/validation/src/index.ts | 1 + packages/validation/src/users.ts | 82 +++++ pnpm-lock.yaml | 38 +++ 65 files changed, 4058 insertions(+), 161 deletions(-) create mode 100644 apps/admin/src/features/auth/login-form.tsx create mode 100644 apps/admin/src/features/auth/require-session.tsx create mode 100644 apps/admin/src/features/auth/session-bar.tsx create mode 100644 apps/admin/src/features/auth/session-provider.tsx create mode 100644 apps/admin/src/features/settings/roles-panel.tsx create mode 100644 apps/admin/src/features/settings/users-table.tsx create mode 100644 apps/api/prisma/create-admin.ts create mode 100644 apps/api/prisma/seed/accounts.ts create mode 100644 apps/api/src/common/security/password.service.ts create mode 100644 apps/api/src/common/security/password.spec.ts create mode 100644 apps/api/src/common/security/security.module.ts create mode 100644 apps/api/src/modules/auth/auth.controller.ts create mode 100644 apps/api/src/modules/auth/auth.repository.ts create mode 100644 apps/api/src/modules/auth/auth.service.ts create mode 100644 apps/api/src/modules/auth/login-throttle.service.ts create mode 100644 apps/api/src/modules/auth/public/index.ts create mode 100644 apps/api/src/modules/auth/refresh-cookie.ts create mode 100644 apps/api/src/modules/auth/token.service.ts create mode 100644 apps/api/src/modules/users/roles.controller.ts create mode 100644 apps/api/src/modules/users/roles.repository.ts create mode 100644 apps/api/src/modules/users/roles.service.ts create mode 100644 apps/api/src/modules/users/users.controller.ts create mode 100644 apps/api/src/modules/users/users.mapper.ts create mode 100644 apps/api/src/modules/users/users.repository.ts create mode 100644 apps/api/src/modules/users/users.service.ts create mode 100644 docs/adr/0015-frontends-reach-the-api-through-their-own-origin.md create mode 100644 packages/api-client/src/http-client.spec.ts create mode 100644 packages/api-client/src/resources/admin.ts create mode 100644 packages/api-client/src/resources/auth.ts create mode 100644 packages/types/src/auth/session.ts create mode 100644 packages/types/src/users/user.ts create mode 100644 packages/validation/src/users.ts diff --git a/README.md b/README.md index f36e4cb..9400702 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ A modern sports-fashion e-commerce platform. -**Status: milestone 1 — catalog read API, live end to end, in Vietnamese and English.** -The storefront renders real products from the database through the REST API. Cart, checkout, -orders and auth are still ahead; see [Roadmap](#roadmap). +**Status: milestone 2 — auth and RBAC, on top of a live bilingual catalog.** +The storefront renders real products in Vietnamese and English; the admin has working sign-in +with rotating refresh tokens and permission-filtered navigation. Cart, checkout and orders are +next; see [Roadmap](#roadmap). ``` Storefront (Next.js) ─┐ @@ -97,8 +98,13 @@ pnpm db:generate # regenerate Prisma Client pnpm db:seed # reconcile permissions + roles (idempotent) pnpm db:studio # Prisma Studio 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 ```bash @@ -153,7 +159,7 @@ sport-store/ │ ├── docs/ │ ├── 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 ├── 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. ([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 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 | | **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 | | **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 | @@ -240,10 +251,10 @@ locale-in-path would buy nothing. | **M8** | Customer account | | **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | -**Recommended next step: M2 (auth).** The enforcement half already exists — global access-token -guard, RBAC permissions guard, audience separation — so only issuance is missing: login, refresh -rotation, and the admin user/role screens. Everything after it (cart ownership, orders, the admin -write path) depends on knowing who is asking. +**Recommended next step: M3 (admin catalog write path).** Reads, auth and RBAC are in place, so +the product editor and variant matrix now have everything they need — a known operator, a +permission to check, and a catalog to edit. It is also what makes the seed replaceable by real +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 format:check` clean -- 5 migrations applied; 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 -- `pnpm test` — 17 passing (RBAC guards, translation fallback, `Accept-Language` negotiation) -- API: listings with filters/facets/cursor paging, PDP, navigation, brands and collections all - return correctly localised payloads in both `vi` and `en` -- Storefront: every route returns 200 in both locales; PDP renders translated options, spec - table and variant titles; `/en/products/` → 307 → `/en/products/`; - `hreflang` + canonical emitted per locale -- Money formats per locale from one integer: `690.000 ₫` (vi) / `₫690,000` (en) +- 5 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, + 3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts +- **25 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language` +- Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in + both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`) +- Storefront: every route 200 in both locales; `/en/products/` → 307 → + `/en/products/`; `hreflang` + canonical emitted per locale +- **Auth:** admin sign-in works through the app's own origin; the refresh cookie is httpOnly and + 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` +### 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 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 diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts index 6bf42d3..ae67cd6 100644 --- a/apps/admin/next.config.ts +++ b/apps/admin/next.config.ts @@ -6,16 +6,48 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { reactStrictMode: true, + + /** + * Proxies API calls through this app's own origin. + * + * The refresh token is a `SameSite=Lax` httpOnly cookie, so the browser only + * sends it first-party. Calling the API host directly from the browser would + * mean `SameSite=None; Secure`, which cannot work over plain HTTP in local + * development at all. Production does the same thing at the Nginx layer, so + * dev and prod share one topology instead of two. + */ + async rewrites() { + const target = process.env.API_INTERNAL_URL ?? 'http://localhost:4000'; + return [{ source: '/api/:path*', destination: `${target}/api/:path*` }]; + }, + transpilePackages: ['@sport/ui'], typedRoutes: true, output: 'standalone', images: { + /** + * Media is served from R2/CDN in production and MinIO locally. + * + * `pathname` and `search` are specified explicitly: Next 16 matches remote + * patterns strictly, and an entry without them does not authorise the URL — + * the optimizer answers `"url" parameter is not allowed` and every product + * image renders broken. Scoping to the bucket path also keeps this from + * becoming an open image proxy. + */ remotePatterns: [ - { protocol: 'http', hostname: 'localhost', port: '9000' }, - { protocol: 'https', hostname: '**.r2.dev' }, - { protocol: 'https', hostname: 'cdn.sport-store.local' }, + { protocol: 'http', hostname: 'localhost', port: '9000', pathname: '/**', search: '' }, + { protocol: 'https', hostname: '**.r2.dev', pathname: '/**', search: '' }, + { protocol: 'https', hostname: 'cdn.sport-store.local', pathname: '/**', search: '' }, ], + + /** + * DEVELOPMENT ONLY. See the storefront config for the full explanation: + * Next 16 blocks upstream images on private IPs (SSRF guard) and reports it + * with the same message as an unmatched pattern. Local MinIO is on + * localhost, production media is on a public CDN host. + */ + dangerouslyAllowLocalIP: process.env.NODE_ENV !== 'production', }, // The admin is an internal tool: keep it out of every index, permanently. diff --git a/apps/admin/src/app/(auth)/login/page.tsx b/apps/admin/src/app/(auth)/login/page.tsx index 7d2c12d..078873a 100644 --- a/apps/admin/src/app/(auth)/login/page.tsx +++ b/apps/admin/src/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; import { LanguageSwitcher } from '@/components/language-switcher'; +import { LoginForm } from '@/features/auth/login-form'; export async function generateMetadata(): Promise { const t = await getTranslations('common.login'); @@ -9,8 +10,8 @@ export async function generateMetadata(): Promise { } /** - * Sits outside the dashboard route group so it renders without the sidebar and - * without the auth requirement. + * Outside the dashboard route group, so it renders without the sidebar and + * without the session requirement. */ export default async function LoginPage() { const t = await getTranslations('common'); @@ -18,13 +19,16 @@ export default async function LoginPage() { return (
-
+

Sport. {t('appName')}

-

{t('login.body')}

+ +

{t('login.body')}

+ +
); diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx index 4d4cb4c..a2e7897 100644 --- a/apps/admin/src/app/(dashboard)/layout.tsx +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -1,27 +1,25 @@ -import { getTranslations } from 'next-intl/server'; - -import { LanguageSwitcher } from '@/components/language-switcher'; import { AdminSidebar } from '@/components/layout/admin-sidebar'; +import { RequireSession } from '@/features/auth/require-session'; +import { SessionBar } from '@/features/auth/session-bar'; /** * Every route in this group requires an authenticated back-office actor. - * Enforcement is layered: the proxy checks for a session cookie, this layout - * verifies the token server-side, and the API re-checks permissions on every - * request. Only the last one is real security; the first two are UX. + * + * Enforcement is layered, and only the last layer is real security: + * 1. `RequireSession` avoids rendering the shell for a signed-out visitor (UX). + * 2. Permission-aware navigation hides screens they cannot use (UX). + * 3. The API authorises every single request (security). */ -export default async function DashboardLayout({ children }: { children: React.ReactNode }) { - const t = await getTranslations('common'); - +export default function DashboardLayout({ children }: { children: React.ReactNode }) { return ( -
- -
-
- - {t('signedOut')} -
-
{children}
+ +
+ +
+ +
{children}
+
-
+ ); } diff --git a/apps/admin/src/app/(dashboard)/settings/roles/page.tsx b/apps/admin/src/app/(dashboard)/settings/roles/page.tsx index fa1b383..f1c48b1 100644 --- a/apps/admin/src/app/(dashboard)/settings/roles/page.tsx +++ b/apps/admin/src/app/(dashboard)/settings/roles/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; -import { PageScaffold } from '@/components/layout/page-scaffold'; +import { RolesPanel } from '@/features/settings/roles-panel'; export async function generateMetadata(): Promise { const t = await getTranslations('pages.roles'); @@ -12,11 +12,13 @@ export default async function RolesPage() { const t = await getTranslations('pages.roles'); return ( - +
+
+

{t('title')}

+

{t('body')}

+
+ + +
); } diff --git a/apps/admin/src/app/(dashboard)/settings/users/page.tsx b/apps/admin/src/app/(dashboard)/settings/users/page.tsx index 88e7c2d..2676a6a 100644 --- a/apps/admin/src/app/(dashboard)/settings/users/page.tsx +++ b/apps/admin/src/app/(dashboard)/settings/users/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; -import { PageScaffold } from '@/components/layout/page-scaffold'; +import { UsersTable } from '@/features/settings/users-table'; export async function generateMetadata(): Promise { const t = await getTranslations('pages.users'); @@ -12,11 +12,13 @@ export default async function UsersPage() { const t = await getTranslations('pages.users'); return ( - +
+
+

{t('title')}

+

{t('body')}

+
+ + +
); } diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index e3623c4..28c1e31 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -4,6 +4,8 @@ import { getLocale, getTranslations } from 'next-intl/server'; import { LOCALE_TAGS } from '@sport/types'; +import { SessionProvider } from '@/features/auth/session-provider'; + import '@/styles/globals.css'; export async function generateMetadata(): Promise { @@ -27,7 +29,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo suppressHydrationWarning > - {children} + + {children} + ); diff --git a/apps/admin/src/components/layout/admin-sidebar.tsx b/apps/admin/src/components/layout/admin-sidebar.tsx index 5222752..2042927 100644 --- a/apps/admin/src/components/layout/admin-sidebar.tsx +++ b/apps/admin/src/components/layout/admin-sidebar.tsx @@ -1,16 +1,36 @@ -import Link from 'next/link'; -import { getTranslations } from 'next-intl/server'; +'use client'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useTranslations } from 'next-intl'; + +import { cn } from '@sport/ui'; + +import { useSession } from '@/features/auth/session-provider'; import { NAVIGATION } from '@/lib/navigation'; /** - * Renders every section for now. Once the session carries permissions, each - * item is filtered with `hasPermission(actor.permissions, item.permission)` — - * the same catalog the API guards read, so menu and enforcement cannot drift. + * Navigation filtered by the signed-in operator's permissions. + * + * This is the visible payoff of RBAC: someone who cannot read orders never sees + * an Orders link, so the admin has no dead ends that 403 on click. It is + * presentation only — the API re-checks every request, because a hidden link is + * one devtools inspection away from being visible. + * + * A client component because the permission set lives in the session. The cost + * is small: the nav is a list of links, and it re-renders only when the session + * changes. */ -export async function AdminSidebar() { - const t = await getTranslations('common'); - const tPages = await getTranslations('pages'); +export function AdminSidebar() { + const t = useTranslations('common'); + const tPages = useTranslations('pages'); + const { can } = useSession(); + const pathname = usePathname(); + + const sections = NAVIGATION.map((section) => ({ + ...section, + items: section.items.filter((item) => can(item.permission)), + })).filter((section) => section.items.length > 0); return (