diff --git a/.dockerignore b/.dockerignore
index c29a8dd7..945b2fc4 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,6 +3,7 @@
**/dist
**/coverage
**/.turbo
+**/.next
**/.env
**/.env.*
!**/.env.example
diff --git a/.gitignore b/.gitignore
index d496dc07..57133b9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,9 @@ dist/
coverage/
.turbo/
*.tsbuildinfo
+# Next.js build output, and the route types written by `next typegen`.
+.next/
+next-env.d.ts
*.log
.env
diff --git a/.prettierignore b/.prettierignore
index 1d227298..1b25708c 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -7,3 +7,5 @@ pnpm-lock.yaml
.expo/
apps/mobile/ios/
apps/mobile/android/
+# Next.js build output (apps/admin).
+.next/
diff --git a/apps/admin/.env.example b/apps/admin/.env.example
new file mode 100644
index 00000000..52615e47
--- /dev/null
+++ b/apps/admin/.env.example
@@ -0,0 +1,19 @@
+# Admin web (apps/admin). Copy to .env.local for development. The values are written in the pages
+# at build time: staging and production each build their own image. Nothing here is a secret:
+# every NEXT_PUBLIC_ value is sent to the browser.
+
+# Origin of the API, without path or trailing slash. The API must list the origin of the admin in
+# its CORS_ORIGINS (http://localhost:5173 locally).
+NEXT_PUBLIC_API_URL=http://localhost:8080
+
+# Web configuration of the Firebase project (Firebase console › Project settings › Your apps).
+# These identify the project and are not secrets; staging and production each have their own.
+# The values below only exist in the local Auth emulator.
+NEXT_PUBLIC_FIREBASE_API_KEY=demo-key
+NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=demo-widoo.firebaseapp.com
+NEXT_PUBLIC_FIREBASE_PROJECT_ID=demo-widoo
+
+# Local Auth emulator (`firebase emulators:start --only auth --project demo-widoo`), the same as
+# FIREBASE_AUTH_EMULATOR_HOST of the API. Accepted with a demo-* project only; empty on staging
+# and production.
+NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL=http://127.0.0.1:9099
diff --git a/apps/admin/AGENTS.md b/apps/admin/AGENTS.md
new file mode 100644
index 00000000..03684c86
--- /dev/null
+++ b/apps/admin/AGENTS.md
@@ -0,0 +1,36 @@
+# Admin web — règles de travail
+
+Outil interne de l'équipe Widoo. Spécification : wiki [Admin-Web](https://github.com/Inprogress-Agency/widoo-app/wiki/Admin-Web) ; écrans de référence : [Ecrans › Admin web](https://github.com/Inprogress-Agency/widoo-app/wiki/Ecrans#admin-web). Les règles communes du dépôt sont dans le `CLAUDE.md` de la racine.
+
+## Architecture des fichiers
+
+| Dossier | Rôle |
+|---|---|
+| `src/app/` | routes seulement : pages fines ; `(shell)/` regroupe les pages derrière la connexion, `connexion/` est A-01 |
+| `src/containers/` | assemblage d'un écran : lit la session et les données par les hooks, passe des props aux composants |
+| `src/components/` | composants d'affichage, sans appel réseau ni règle métier : `shell/`, `sign-in/`, `states/` |
+| `src/hooks/` | logique d'écran (`useSignIn`, `useSession`, `useMediaQuery`) |
+| `src/lib/` | session et fournisseur d'identité (`auth/`), page de retour, en-têtes de réponse |
+| `src/config/` | navigation par rôle, variables d'environnement validées |
+| `src/messages/` | textes, en français seulement |
+
+Les composants communs (bouton, champ, encadré, menu, info-bulle) et le thème shadcn tiré de `tokens.json` sont dans `packages/ui`.
+
+## Règles
+
+- **Aucune donnée en rendu serveur.** Les pages sont des coques statiques ; tout appel à l'API part du navigateur, par `packages/api-client`, avec le jeton de la personne connectée.
+- **L'API décide.** Le rôle est vérifié par l'API à chaque appel ; l'admin ne fait que cacher ce que le rôle ne permet pas (`packages/shared`, `admin-sections.ts`).
+- **Aucun texte en dur.** Chaque texte visible vient de `src/messages/fr.ts`.
+- **Aucune couleur ni valeur de style en dur.** Classes du preset `@widoo/ui/tailwind-preset` seulement ; pas de valeur arbitraire (`text-[15px]`). Une taille des planches absente de `tokens.json` s'ajoute au preset avec la mention « à reporter dans tokens.json ».
+- **Aucune valeur d'environnement en dur.** `src/config/env.ts`, documentées dans `.env.example` ; rien de secret : tout `NEXT_PUBLIC_` part dans le navigateur.
+- **Jamais indexé, jamais mis en cache** : en-têtes de `src/lib/response-headers.ts`.
+- **Imports** par l'alias `@/`.
+
+## Commandes
+
+```bash
+cp apps/admin/.env.example apps/admin/.env.local
+pnpm --filter @widoo/admin dev # http://localhost:5173
+pnpm --filter @widoo/admin build && pnpm --filter @widoo/admin start
+pnpm --filter @widoo/admin lint && pnpm --filter @widoo/admin typecheck && pnpm --filter @widoo/admin test
+```
diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts
new file mode 100644
index 00000000..d500bc14
--- /dev/null
+++ b/apps/admin/next.config.ts
@@ -0,0 +1,36 @@
+import type { NextConfig } from 'next';
+import { parseEnv } from './src/config/env';
+import { responseHeaders } from './src/lib/response-headers';
+
+const dev = process.env.NODE_ENV === 'development';
+
+const nextConfig: NextConfig = {
+ // Self-contained server for the Docker image of Cloud Run.
+ output: 'standalone',
+ // The monorepo root, so that the standalone output traces the workspace packages.
+ outputFileTracingRoot: new URL('../../', import.meta.url).pathname,
+ // Workspace packages shipped as TypeScript sources.
+ transpilePackages: ['@widoo/ui', '@widoo/shared', '@widoo/api-client'],
+ poweredByHeader: false,
+ reactStrictMode: true,
+ // apps/admin/AGENTS.md holds the rules of the admin: `next dev` must not append its own block.
+ agentRules: false,
+ // The environment is read here, when the server or the build asks for the headers, and not
+ // when this file loads: `next typegen` (typecheck, CI) needs no environment.
+ headers: async () => {
+ const env = parseEnv(process.env);
+ return [
+ {
+ source: '/:path*',
+ headers: responseHeaders({
+ dev,
+ apiOrigin: env.NEXT_PUBLIC_API_URL,
+ authDomain: env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
+ authEmulatorOrigin: env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL,
+ }),
+ },
+ ];
+ },
+};
+
+export default nextConfig;
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 321397c7..40e0bde4 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -4,7 +4,33 @@
"private": true,
"type": "module",
"scripts": {
+ "dev": "next dev --port 5173",
+ "build": "next build",
+ "start": "next start --port 5173",
"lint": "eslint .",
- "typecheck": "tsc"
+ "typecheck": "next typegen && tsc",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@firebase/app": "^0.16.3",
+ "@firebase/auth": "^1.13.7",
+ "@phosphor-icons/react": "^2.1.10",
+ "@widoo/api-client": "workspace:*",
+ "@widoo/shared": "workspace:*",
+ "@widoo/ui": "workspace:*",
+ "next": "16.3.8",
+ "qrcode": "^1.5.4",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "zod": "^4.6.5"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.4",
+ "@types/qrcode": "^1.5.6",
+ "@types/react": "~19.2.2",
+ "@types/react-dom": "~19.2.2",
+ "autoprefixer": "^10.6.1",
+ "postcss": "^8.5.28",
+ "tailwindcss": "3.4.19"
}
}
diff --git a/apps/admin/postcss.config.cjs b/apps/admin/postcss.config.cjs
new file mode 100644
index 00000000..f698de1d
--- /dev/null
+++ b/apps/admin/postcss.config.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ plugins: { tailwindcss: {}, autoprefixer: {} },
+};
diff --git a/apps/admin/public/brand/google-g.svg b/apps/admin/public/brand/google-g.svg
new file mode 100644
index 00000000..58150186
--- /dev/null
+++ b/apps/admin/public/brand/google-g.svg
@@ -0,0 +1 @@
+
diff --git a/apps/admin/public/brand/widoo-icon.svg b/apps/admin/public/brand/widoo-icon.svg
new file mode 100644
index 00000000..5e963369
--- /dev/null
+++ b/apps/admin/public/brand/widoo-icon.svg
@@ -0,0 +1,27 @@
+
diff --git a/apps/admin/src/app/(shell)/[...rest]/page.tsx b/apps/admin/src/app/(shell)/[...rest]/page.tsx
new file mode 100644
index 00000000..769d0a35
--- /dev/null
+++ b/apps/admin/src/app/(shell)/[...rest]/page.tsx
@@ -0,0 +1,6 @@
+import { notFound } from 'next/navigation';
+
+/** Any other address under the shell: the missing page, shown inside the shell. */
+export default function UnknownPage() {
+ notFound();
+}
diff --git a/apps/admin/src/app/(shell)/layout.tsx b/apps/admin/src/app/(shell)/layout.tsx
new file mode 100644
index 00000000..718d7b2f
--- /dev/null
+++ b/apps/admin/src/app/(shell)/layout.tsx
@@ -0,0 +1,6 @@
+import { ShellContainer } from '@/containers/ShellContainer';
+import type { ReactNode } from 'react';
+
+export default function ShellLayout({ children }: { children: ReactNode }) {
+ return {children};
+}
diff --git a/apps/admin/src/app/(shell)/not-found.tsx b/apps/admin/src/app/(shell)/not-found.tsx
new file mode 100644
index 00000000..974647b3
--- /dev/null
+++ b/apps/admin/src/app/(shell)/not-found.tsx
@@ -0,0 +1,19 @@
+import { PageMessage } from '@/components/states/PageMessage';
+import { sectionPaths } from '@/config/navigation';
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+import Link from 'next/link';
+
+export default function NotFound() {
+ return (
+
+ {messages.notFound.back}
+
+ }
+ />
+ );
+}
diff --git a/apps/admin/src/app/(shell)/page.tsx b/apps/admin/src/app/(shell)/page.tsx
new file mode 100644
index 00000000..890f7b5a
--- /dev/null
+++ b/apps/admin/src/app/(shell)/page.tsx
@@ -0,0 +1,7 @@
+/**
+ * A-05, the dashboard: its tiles and figures come with the ticket of the dashboard (#55). The
+ * shell around it already shows its title.
+ */
+export default function DashboardPage() {
+ return null;
+}
diff --git a/apps/admin/src/app/connexion/page.tsx b/apps/admin/src/app/connexion/page.tsx
new file mode 100644
index 00000000..bac37cad
--- /dev/null
+++ b/apps/admin/src/app/connexion/page.tsx
@@ -0,0 +1,11 @@
+import { SignInContainer } from '@/containers/SignInContainer';
+import { Suspense } from 'react';
+
+/** A-01. The page asked for is read from the address, in the browser only. */
+export default function SignInPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css
new file mode 100644
index 00000000..b5c61c95
--- /dev/null
+++ b/apps/admin/src/app/globals.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/apps/admin/src/app/icon.svg b/apps/admin/src/app/icon.svg
new file mode 100644
index 00000000..5e963369
--- /dev/null
+++ b/apps/admin/src/app/icon.svg
@@ -0,0 +1,27 @@
+
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
new file mode 100644
index 00000000..7f7b38b8
--- /dev/null
+++ b/apps/admin/src/app/layout.tsx
@@ -0,0 +1,34 @@
+import './globals.css';
+import { SessionProvider } from '@/lib/auth/SessionProvider';
+import { messages } from '@/messages/fr';
+import { Toaster } from '@widoo/ui';
+import type { Metadata } from 'next';
+import { Plus_Jakarta_Sans } from 'next/font/google';
+import type { ReactNode } from 'react';
+
+// Self-hosted at build time: no request to Google from the browser.
+const jakarta = Plus_Jakarta_Sans({
+ subsets: ['latin'],
+ // The weights of the board « Typographie » (wiki Ecrans › Admin web).
+ weight: ['400', '500', '600', '700', '800'],
+ display: 'swap',
+ variable: '--font-jakarta',
+});
+
+export const metadata: Metadata = {
+ title: messages.app.title,
+ robots: { index: false, follow: false },
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+ // Browser extensions write attributes on before React loads: accepted on this element
+ // only, its children are still checked.
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/robots.ts b/apps/admin/src/app/robots.ts
new file mode 100644
index 00000000..b3cfc79a
--- /dev/null
+++ b/apps/admin/src/app/robots.ts
@@ -0,0 +1,6 @@
+import type { MetadataRoute } from 'next';
+
+/** No page of the admin is public: closed to every crawler, on top of the `noindex` header. */
+export default function robots(): MetadataRoute.Robots {
+ return { rules: { userAgent: '*', disallow: '/' } };
+}
diff --git a/apps/admin/src/components/confirm/ReasonFields.tsx b/apps/admin/src/components/confirm/ReasonFields.tsx
new file mode 100644
index 00000000..3993c03a
--- /dev/null
+++ b/apps/admin/src/components/confirm/ReasonFields.tsx
@@ -0,0 +1,60 @@
+import { messages } from '@/messages/fr';
+import { Field, Select, Textarea } from '@widoo/ui';
+
+export type ReasonOption = { code: string; label: string };
+
+type ReasonFieldsProps = {
+ /** Prefix of the `id` of the two controls, unique on the page. */
+ id: string;
+ /** The codified reasons of the action, in the order of the wiki. */
+ options: readonly ReasonOption[];
+ reasonCode: string;
+ onReasonCodeChange: (code: string) => void;
+ detail: string;
+ onDetailChange: (detail: string) => void;
+};
+
+/**
+ * The reason of a destructive action: a codified reason and its detail, both required (wiki
+ * Admin-Web › Modèles communs). Goes inside a `ConfirmDialog`, whose form checks them.
+ */
+export function ReasonFields({
+ id,
+ options,
+ reasonCode,
+ onReasonCodeChange,
+ detail,
+ onDetailChange,
+}: ReasonFieldsProps) {
+ const texts = messages.confirm;
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/components/shell/AccountMenu.tsx b/apps/admin/src/components/shell/AccountMenu.tsx
new file mode 100644
index 00000000..2045f861
--- /dev/null
+++ b/apps/admin/src/components/shell/AccountMenu.tsx
@@ -0,0 +1,57 @@
+import { messages } from '@/messages/fr';
+import { CaretDownIcon, SignOutIcon } from '@phosphor-icons/react/dist/ssr';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@widoo/ui';
+
+type AccountMenuProps = {
+ /** First name of the member, or the address when the account has none. */
+ name: string;
+ email: string | null;
+ /** Role as the team reads it: « Admin », « Modérateur », « Éditeur ». */
+ roleLabel: string;
+ onSignOut: () => void;
+};
+
+/** The account, on the right of the header: initial, name and role, opening the sign-out menu. */
+export function AccountMenu({ name, email, roleLabel, onSignOut }: AccountMenuProps) {
+ return (
+
+
+
+ {name.charAt(0).toUpperCase()}
+
+ {/* Below 640 px the initial alone: the name and the role stay in the label and the menu. */}
+
+ {name}
+ {roleLabel}
+
+
+
+
+
+
+
+
+ {messages.shell.signOut}
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/shell/AdminShell.tsx b/apps/admin/src/components/shell/AdminShell.tsx
new file mode 100644
index 00000000..5efeb830
--- /dev/null
+++ b/apps/admin/src/components/shell/AdminShell.tsx
@@ -0,0 +1,27 @@
+import type { ReactNode } from 'react';
+
+type AdminShellProps = {
+ /** The navigation, on the left. */
+ sidebar: ReactNode;
+ /** Title of the page, in the header of 64 px. */
+ title: string;
+ /** The account, on the right of the header. */
+ account: ReactNode;
+ children: ReactNode;
+};
+
+/** Frame of every page of the admin: navigation on the left, header, then the page. */
+export function AdminShell({ sidebar, title, account, children }: AdminShellProps) {
+ return (
+
+ {sidebar}
+
+
+
{title}
+ {account}
+
+ {children}
+
+
+ );
+}
diff --git a/apps/admin/src/components/shell/AdminSidebar.tsx b/apps/admin/src/components/shell/AdminSidebar.tsx
new file mode 100644
index 00000000..aa5104fb
--- /dev/null
+++ b/apps/admin/src/components/shell/AdminSidebar.tsx
@@ -0,0 +1,97 @@
+import { messages } from '@/messages/fr';
+import { SidebarSimpleIcon } from '@phosphor-icons/react/dist/ssr';
+import { cn } from '@widoo/ui';
+import type { AdminSection } from '@widoo/shared';
+import Link from 'next/link';
+import { sectionIcons } from './section-icons';
+import { SidebarLink, type SidebarCounter } from './SidebarLink';
+import { WidooLogo } from './WidooLogo';
+
+export type SidebarEntry = {
+ section: AdminSection;
+ href: string;
+ label: string;
+ counter?: SidebarCounter;
+};
+export type SidebarGroup = { id: string; label: string; entries: SidebarEntry[] };
+
+type AdminSidebarProps = {
+ /** The dashboard, alone above the groups. */
+ home: SidebarEntry;
+ /** The groups of the role, each with the sections it opens. */
+ groups: SidebarGroup[];
+ activeSection: AdminSection | null;
+ /** Reduced to its icons: 72 px instead of 248 px. */
+ isIconOnly: boolean;
+ /** Absent when the window is too narrow for the full menu: nothing to toggle. */
+ onToggle?: () => void;
+};
+
+/** Navigation of the admin, on warm gray: logo, dashboard, then the sections of the role. */
+export function AdminSidebar({
+ home,
+ groups,
+ activeSection,
+ isIconOnly,
+ onToggle,
+}: AdminSidebarProps) {
+ const renderEntry = (entry: SidebarEntry) => (
+
+
+
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/shell/SidebarLink.tsx b/apps/admin/src/components/shell/SidebarLink.tsx
new file mode 100644
index 00000000..5d6086dc
--- /dev/null
+++ b/apps/admin/src/components/shell/SidebarLink.tsx
@@ -0,0 +1,66 @@
+import type { Icon } from '@phosphor-icons/react';
+import { cn, CountBadge, Tooltip } from '@widoo/ui';
+import Link from 'next/link';
+
+type SidebarLinkProps = {
+ href: string;
+ label: string;
+ icon: Icon;
+ /** The section being shown: white ground, filled blue icon, `aria-current`. */
+ isActive: boolean;
+ /** Reduced menu: the icon alone, the label in a tooltip on hover and on focus. */
+ isIconOnly: boolean;
+ /** What waits in the section, when something does. */
+ counter?: SidebarCounter;
+};
+
+export type SidebarCounter = {
+ count: number;
+ /** Something has been waiting for more than 72 hours: the pill turns red. */
+ isLate: boolean;
+ /** The counter said in full: « 12 parcours à relire, dont 2 en retard ». */
+ label: string;
+};
+
+/** An entry of the navigation: a link of 36 px with the icon of its section. */
+export function SidebarLink({
+ href,
+ label,
+ icon: IconOf,
+ isActive,
+ isIconOnly,
+ counter,
+}: SidebarLinkProps) {
+ const badge = counter ? (
+
+ ) : null;
+ const link = (
+
+
+ {isIconOnly ? null : label}
+ {badge}
+
+ );
+ if (!isIconOnly) {
+ return link;
+ }
+ return {link};
+}
diff --git a/apps/admin/src/components/shell/WidooLogo.tsx b/apps/admin/src/components/shell/WidooLogo.tsx
new file mode 100644
index 00000000..120d726f
--- /dev/null
+++ b/apps/admin/src/components/shell/WidooLogo.tsx
@@ -0,0 +1,25 @@
+import { messages } from '@/messages/fr';
+import Image from 'next/image';
+
+/**
+ * Logo of the admin: the icon of the app, then « Widoo » and « Admin ». `isIconOnly` in the
+ * reduced menu, where the name stays for screen readers.
+ */
+export function WidooLogo({ isIconOnly = false }: { isIconOnly?: boolean }) {
+ return (
+
+
+
+ {messages.app.name}
+ {messages.app.product}
+
+
+ );
+}
diff --git a/apps/admin/src/components/shell/section-icons.ts b/apps/admin/src/components/shell/section-icons.ts
new file mode 100644
index 00000000..6eed544a
--- /dev/null
+++ b/apps/admin/src/components/shell/section-icons.ts
@@ -0,0 +1,32 @@
+import {
+ ChatCircleDotsIcon,
+ ClockCounterClockwiseIcon,
+ FlagIcon,
+ HandshakeIcon,
+ LinkSimpleIcon,
+ MagicWandIcon,
+ MapTrifoldIcon,
+ ShieldCheckIcon,
+ SlidersHorizontalIcon,
+ SquaresFourIcon,
+ TranslateIcon,
+ UsersIcon,
+} from '@phosphor-icons/react/dist/ssr';
+import type { Icon } from '@phosphor-icons/react';
+import type { AdminSection } from '@widoo/shared';
+
+/** Phosphor icon of each section, the ones of the board « Coque » (wiki Ecrans › Admin web). */
+export const sectionIcons: Record = {
+ dashboard: SquaresFourIcon,
+ moderation: ShieldCheckIcon,
+ reports: FlagIcon,
+ support: ChatCircleDotsIcon,
+ catalog: MapTrifoldIcon,
+ aiDrafts: MagicWandIcon,
+ translations: TranslateIcon,
+ users: UsersIcon,
+ affiliation: LinkSimpleIcon,
+ b2bClients: HandshakeIcon,
+ settings: SlidersHorizontalIcon,
+ actionLog: ClockCounterClockwiseIcon,
+};
diff --git a/apps/admin/src/components/sign-in/CodeField.tsx b/apps/admin/src/components/sign-in/CodeField.tsx
new file mode 100644
index 00000000..a7b4621e
--- /dev/null
+++ b/apps/admin/src/components/sign-in/CodeField.tsx
@@ -0,0 +1,31 @@
+import { messages } from '@/messages/fr';
+import { Field, Input } from '@widoo/ui';
+
+type CodeFieldProps = {
+ id: string;
+ code: string;
+ onCodeChange: (code: string) => void;
+};
+
+/** Length of a code of an authenticator app. */
+export const codeLength = 6;
+
+/** The 6-digit code of an authenticator app: digits only, offered by the keyboard of a phone. */
+export function CodeField({ id, code, onCodeChange }: CodeFieldProps) {
+ return (
+
+ onCodeChange(event.target.value.replace(/\D/g, ''))}
+ />
+
+ );
+}
diff --git a/apps/admin/src/components/sign-in/LinkSentPanel.tsx b/apps/admin/src/components/sign-in/LinkSentPanel.tsx
new file mode 100644
index 00000000..decc5620
--- /dev/null
+++ b/apps/admin/src/components/sign-in/LinkSentPanel.tsx
@@ -0,0 +1,50 @@
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+import type { ReactNode } from 'react';
+
+type LinkSentPanelProps = {
+ /** A failed resend, above the text. */
+ notices: ReactNode;
+ email: string;
+ /** Seconds before the link may be sent again; 0 once allowed. */
+ secondsLeft: number;
+ onResend: () => void;
+ onOtherAddress: () => void;
+ isBusy: boolean;
+};
+
+/** A-01, « lien envoyé »: where the link went, when to ask again, or another address. */
+export function LinkSentPanel({
+ notices,
+ email,
+ secondsLeft,
+ onResend,
+ onOtherAddress,
+ isBusy,
+}: LinkSentPanelProps) {
+ const texts = messages.signIn.linkSent;
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/apps/admin/src/components/sign-in/SecondFactorChallenge.tsx b/apps/admin/src/components/sign-in/SecondFactorChallenge.tsx
new file mode 100644
index 00000000..762c64fa
--- /dev/null
+++ b/apps/admin/src/components/sign-in/SecondFactorChallenge.tsx
@@ -0,0 +1,49 @@
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+import type { FormEvent, ReactNode } from 'react';
+import { CodeField } from './CodeField';
+
+type SecondFactorChallengeProps = {
+ /** A refused code, above the field. */
+ notices: ReactNode;
+ code: string;
+ onCodeChange: (code: string) => void;
+ onSubmit: () => void;
+ /** Gives up this sign-in to try another account. */
+ onOtherAccount: () => void;
+ isBusy: boolean;
+};
+
+/** After Google or the e-mail link: the code of the authenticator app, to finish signing in. */
+export function SecondFactorChallenge({
+ notices,
+ code,
+ onCodeChange,
+ onSubmit,
+ onOtherAccount,
+ isBusy,
+}: SecondFactorChallengeProps) {
+ const texts = messages.signIn.challenge;
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ onSubmit();
+ };
+ return (
+ <>
+
+
{texts.title}
+
{texts.text}
+
+ {notices}
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/components/sign-in/SignInForm.tsx b/apps/admin/src/components/sign-in/SignInForm.tsx
new file mode 100644
index 00000000..716e972f
--- /dev/null
+++ b/apps/admin/src/components/sign-in/SignInForm.tsx
@@ -0,0 +1,68 @@
+import { messages } from '@/messages/fr';
+import { EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr';
+import { Button, Input, Label } from '@widoo/ui';
+import Image from 'next/image';
+import type { FormEvent, ReactNode } from 'react';
+
+type SignInFormProps = {
+ /** Messages above the choices: the page asked for, a refusal, an error. */
+ notices: ReactNode;
+ email: string;
+ onEmailChange: (email: string) => void;
+ onGoogle: () => void;
+ onSendLink: () => void;
+ /** An attempt is running: the choices wait for its answer. */
+ isBusy: boolean;
+};
+
+/** A-01: « Continuer avec Google », or an address to receive a sign-in link. */
+export function SignInForm({
+ notices,
+ email,
+ onEmailChange,
+ onGoogle,
+ onSendLink,
+ isBusy,
+}: SignInFormProps) {
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ onSendLink();
+ };
+
+ return (
+ <>
+
+
{messages.signIn.title}
+
{messages.signIn.subtitle}
+
+ {notices}
+
+
+
+ {messages.signIn.or}
+
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/components/sign-in/SignInLayout.tsx b/apps/admin/src/components/sign-in/SignInLayout.tsx
new file mode 100644
index 00000000..14289b92
--- /dev/null
+++ b/apps/admin/src/components/sign-in/SignInLayout.tsx
@@ -0,0 +1,16 @@
+import { WidooLogo } from '@/components/shell/WidooLogo';
+import { messages } from '@/messages/fr';
+import type { ReactNode } from 'react';
+
+/** Page of A-01: warm gray, the logo, a white card of 440 px, then the line of help. */
+export function SignInLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
{messages.signIn.help}
+
+ );
+}
diff --git a/apps/admin/src/components/sign-in/SignInNotices.tsx b/apps/admin/src/components/sign-in/SignInNotices.tsx
new file mode 100644
index 00000000..e4e8cf31
--- /dev/null
+++ b/apps/admin/src/components/sign-in/SignInNotices.tsx
@@ -0,0 +1,54 @@
+import type { SignInFailure } from '@/lib/auth/auth-client';
+import type { SignInRefusal } from '@/lib/auth/session';
+import { messages } from '@/messages/fr';
+import { InfoIcon, WarningCircleIcon } from '@phosphor-icons/react/dist/ssr';
+import { Notice } from '@widoo/ui';
+import type { AdminSection } from '@widoo/shared';
+
+type SignInNoticesProps = {
+ /** The section asked for before signing in; null for the dashboard. */
+ returnSection: AdminSection | null;
+ refusal: SignInRefusal | null;
+ failure: SignInFailure | null;
+ /** The API ended the session: said first in the reminder of the page asked for. */
+ hasExpired: boolean;
+};
+
+/**
+ * Messages above the choices of A-01. A refusal or an error replaces the reminder of the page
+ * asked for: one message at a time.
+ */
+export function SignInNotices({ returnSection, refusal, failure, hasExpired }: SignInNoticesProps) {
+ const texts = messages.signIn;
+ const errorIcon = ;
+
+ if (refusal) {
+ const { title, detail } = texts.refusals[refusal.reason];
+ return (
+
+
+
+ );
+ }
+ return null;
+}
diff --git a/apps/admin/src/components/sign-in/TotpEnrollment.tsx b/apps/admin/src/components/sign-in/TotpEnrollment.tsx
new file mode 100644
index 00000000..766f4f67
--- /dev/null
+++ b/apps/admin/src/components/sign-in/TotpEnrollment.tsx
@@ -0,0 +1,78 @@
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+import Image from 'next/image';
+import type { FormEvent, ReactNode } from 'react';
+import { CodeField } from './CodeField';
+
+type TotpEnrollmentProps = {
+ /** A refused code or a failed preparation, above the steps. */
+ notices: ReactNode;
+ /** The QR code as an image address, and the same secret as text; null while being prepared. */
+ setup: { qrCodeImage: string; secretKey: string } | null;
+ code: string;
+ onCodeChange: (code: string) => void;
+ onSubmit: () => void;
+ onOtherAccount: () => void;
+ isBusy: boolean;
+};
+
+/**
+ * First sign-in of a member of the team: add the account to an authenticator app by its QR code
+ * or its key, then prove it with a first code (D-087).
+ */
+export function TotpEnrollment({
+ notices,
+ setup,
+ code,
+ onCodeChange,
+ onSubmit,
+ onOtherAccount,
+ isBusy,
+}: TotpEnrollmentProps) {
+ const texts = messages.signIn.enrollment;
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ onSubmit();
+ };
+ return (
+ <>
+
+
{texts.title}
+
{texts.text}
+
+ {notices}
+ {setup ? (
+
+
{texts.scan}
+
+
{texts.manual}
+
+ {setup.secretKey}
+
+
{texts.keepSecret}
+
+ ) : (
+
+ {texts.preparing}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/components/states/PageMessage.tsx b/apps/admin/src/components/states/PageMessage.tsx
new file mode 100644
index 00000000..73e736b2
--- /dev/null
+++ b/apps/admin/src/components/states/PageMessage.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+
+type PageMessageProps = {
+ title: string;
+ text?: string;
+ /** The useful action: « Réessayer », a link back. */
+ action?: ReactNode;
+ /** `alert` for an error, announced at once; `status` for a wait. */
+ role?: 'alert' | 'status';
+};
+
+/** A message alone in the middle of a page or of the window: wait, error, page not found. */
+export function PageMessage({ title, text, action, role }: PageMessageProps) {
+ return (
+
+
{title}
+ {text ?
{text}
: null}
+ {action}
+
+ );
+}
diff --git a/apps/admin/src/components/states/UnavailableMessage.tsx b/apps/admin/src/components/states/UnavailableMessage.tsx
new file mode 100644
index 00000000..5b4b397c
--- /dev/null
+++ b/apps/admin/src/components/states/UnavailableMessage.tsx
@@ -0,0 +1,27 @@
+import { PageMessage } from '@/components/states/PageMessage';
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+
+type UnavailableMessageProps = {
+ onRetry: () => void;
+ /** Signs out, so that another account can sign in. */
+ onOtherAccount: () => void;
+};
+
+/** The API did not answer when the account was read: try again, or use another account. */
+export function UnavailableMessage({ onRetry, onOtherAccount }: UnavailableMessageProps) {
+ return (
+
+
+
+
+ }
+ />
+ );
+}
diff --git a/apps/admin/src/config/env.test.ts b/apps/admin/src/config/env.test.ts
new file mode 100644
index 00000000..b3441f60
--- /dev/null
+++ b/apps/admin/src/config/env.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+import { parseEnv } from './env';
+
+const valid = {
+ NEXT_PUBLIC_API_URL: 'http://localhost:8080',
+ NEXT_PUBLIC_FIREBASE_API_KEY: 'demo-key',
+ NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: 'demo-widoo.firebaseapp.com',
+ NEXT_PUBLIC_FIREBASE_PROJECT_ID: 'demo-widoo',
+};
+
+describe('parseEnv', () => {
+ it('reads the origin of the API and the Firebase project', () => {
+ expect(parseEnv(valid)).toEqual({
+ ...valid,
+ NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: undefined,
+ });
+ });
+
+ it.each([
+ ['a missing origin', undefined],
+ ['a trailing slash', 'https://api.example/'],
+ ['a path', 'https://api.example/v1'],
+ ['another scheme', 'ftp://api.example'],
+ ])('refuses %s for the API', (_, value) => {
+ expect(() => parseEnv({ ...valid, NEXT_PUBLIC_API_URL: value })).toThrow(
+ /Invalid admin environment/,
+ );
+ });
+
+ it('refuses a missing Firebase project', () => {
+ expect(() => parseEnv({ ...valid, NEXT_PUBLIC_FIREBASE_PROJECT_ID: undefined })).toThrow(
+ /NEXT_PUBLIC_FIREBASE_PROJECT_ID/,
+ );
+ });
+
+ it('accepts the Auth emulator with a demo project', () => {
+ const env = parseEnv({
+ ...valid,
+ NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: 'http://127.0.0.1:9099',
+ });
+ expect(env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL).toBe('http://127.0.0.1:9099');
+ });
+
+ it('reads an empty emulator address as none', () => {
+ const env = parseEnv({ ...valid, NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: '' });
+ expect(env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL).toBeUndefined();
+ });
+
+ it('refuses the Auth emulator with a real project', () => {
+ expect(() =>
+ parseEnv({
+ ...valid,
+ NEXT_PUBLIC_FIREBASE_PROJECT_ID: 'widoo-staging',
+ NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: 'http://127.0.0.1:9099',
+ }),
+ ).toThrow(/demo-\* project/);
+ });
+});
diff --git a/apps/admin/src/config/env.ts b/apps/admin/src/config/env.ts
new file mode 100644
index 00000000..b0006793
--- /dev/null
+++ b/apps/admin/src/config/env.ts
@@ -0,0 +1,54 @@
+import { z } from 'zod';
+
+const Origin = z
+ .url({ protocol: /^https?$/ })
+ .refine(
+ (value) => URL.canParse(value) && new URL(value).origin === value,
+ 'Expected an origin like https://host, without path or trailing slash',
+ );
+
+/** A host name, without scheme or path: `project.firebaseapp.com`. */
+const Host = z.string().regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/);
+
+/**
+ * Environment variables of the admin, validated once. See `.env.example`. Every value is public:
+ * Next.js writes the `NEXT_PUBLIC_` variables in the pages at build time.
+ */
+const Env = z.object({
+ /** Origin of the API the admin calls from the browser. */
+ NEXT_PUBLIC_API_URL: Origin,
+ /**
+ * Web configuration of the Firebase project (Firebase console › Project settings): identifiers
+ * of the project, not secrets. Staging and production each have their own project.
+ */
+ NEXT_PUBLIC_FIREBASE_API_KEY: z.string().min(1),
+ NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: Host,
+ NEXT_PUBLIC_FIREBASE_PROJECT_ID: z.string().regex(/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/),
+ /** Local Auth emulator; empty on staging and production. */
+ NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: z
+ .string()
+ .optional()
+ .transform((value) => value || undefined)
+ .pipe(Origin.optional()),
+});
+
+export type AdminEnv = z.infer;
+
+export function parseEnv(source: Record): AdminEnv {
+ const result = Env.safeParse(source);
+ if (result.success && result.data.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL) {
+ // The emulator accepts unsigned tokens: only with a demo project, which exists nowhere else.
+ if (!result.data.NEXT_PUBLIC_FIREBASE_PROJECT_ID.startsWith('demo-')) {
+ throw new Error(
+ 'Invalid admin environment: NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL needs a demo-* project',
+ );
+ }
+ }
+ if (!result.success) {
+ const details = result.error.issues
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
+ .join('; ');
+ throw new Error(`Invalid admin environment: ${details}`);
+ }
+ return result.data;
+}
diff --git a/apps/admin/src/config/navigation.test.ts b/apps/admin/src/config/navigation.test.ts
new file mode 100644
index 00000000..10c35ec6
--- /dev/null
+++ b/apps/admin/src/config/navigation.test.ts
@@ -0,0 +1,57 @@
+import { adminSections } from '@widoo/shared';
+import { describe, expect, it } from 'vitest';
+import { navigationFor, sectionOfPath, sectionPaths } from './navigation';
+
+const sectionsOf = (role: Parameters[0]) =>
+ navigationFor(role).map((group) => [group.id, group.sections]);
+
+// The three menus of the board « Coque » (wiki Ecrans › Admin web).
+describe('navigationFor', () => {
+ it('gives an editor the catalog and the revenue', () => {
+ expect(sectionsOf('editor')).toEqual([
+ ['catalog', ['catalog', 'aiDrafts', 'translations']],
+ ['revenue', ['affiliation', 'b2bClients']],
+ ]);
+ });
+
+ it('gives a moderator what is to handle, the catalog and the community', () => {
+ expect(sectionsOf('moderator')).toEqual([
+ ['toHandle', ['moderation', 'reports', 'support']],
+ ['catalog', ['catalog', 'translations']],
+ ['community', ['users']],
+ ]);
+ });
+
+ it('gives an admin every section but the dashboard, shown apart', () => {
+ const sections = navigationFor('admin').flatMap((group) => group.sections);
+ expect([...sections].sort()).toEqual(
+ adminSections.filter((section) => section !== 'dashboard').sort(),
+ );
+ });
+
+ it('gives a user nothing', () => {
+ expect(navigationFor('user')).toEqual([]);
+ });
+});
+
+describe('sectionPaths', () => {
+ it('gives each section its own address', () => {
+ const paths = Object.values(sectionPaths);
+ expect(new Set(paths).size).toBe(paths.length);
+ });
+});
+
+describe('sectionOfPath', () => {
+ it.each([
+ ['/', 'dashboard'],
+ ['/moderation', 'moderation'],
+ ['/moderation/42', 'moderation'],
+ ['/brouillons-ia', 'aiDrafts'],
+ ])('finds the section of %s', (pathname, section) => {
+ expect(sectionOfPath(pathname)).toBe(section);
+ });
+
+ it.each(['/inconnu', '/moderations', '/connexion'])('finds none for %s', (pathname) => {
+ expect(sectionOfPath(pathname)).toBeNull();
+ });
+});
diff --git a/apps/admin/src/config/navigation.ts b/apps/admin/src/config/navigation.ts
new file mode 100644
index 00000000..5ef5f6ce
--- /dev/null
+++ b/apps/admin/src/config/navigation.ts
@@ -0,0 +1,60 @@
+import { adminSectionsOf, type AdminSection, type UserRole } from '@widoo/shared';
+
+/** Address of each section. The dashboard is the home page of the admin. */
+export const sectionPaths = {
+ dashboard: '/',
+ moderation: '/moderation',
+ reports: '/signalements',
+ support: '/support',
+ catalog: '/catalogue',
+ aiDrafts: '/brouillons-ia',
+ translations: '/traductions',
+ users: '/utilisateurs',
+ affiliation: '/affiliation',
+ b2bClients: '/partenaires',
+ settings: '/reglages',
+ actionLog: '/journal',
+} as const satisfies Record;
+
+/** Groups of the navigation, in the order of the board « Coque » (wiki Ecrans › Admin web). */
+const navigationGroups = [
+ { id: 'toHandle', sections: ['moderation', 'reports', 'support'] },
+ { id: 'catalog', sections: ['catalog', 'aiDrafts', 'translations'] },
+ { id: 'community', sections: ['users'] },
+ { id: 'revenue', sections: ['affiliation', 'b2bClients'] },
+ { id: 'administration', sections: ['settings', 'actionLog'] },
+] as const satisfies readonly { id: string; sections: readonly AdminSection[] }[];
+
+export type NavigationGroupId = (typeof navigationGroups)[number]['id'];
+
+export type NavigationGroup = { id: NavigationGroupId; sections: AdminSection[] };
+
+/**
+ * The navigation of a role: the groups that hold at least one of its sections, each with these
+ * sections only. The dashboard stands alone above the groups, so it is not part of them.
+ */
+export function navigationFor(role: UserRole): NavigationGroup[] {
+ const opened = new Set(adminSectionsOf(role));
+ return navigationGroups
+ .map(({ id, sections }) => ({
+ id,
+ sections: sections.filter((section: AdminSection) => opened.has(section)),
+ }))
+ .filter((group) => group.sections.length > 0);
+}
+
+/**
+ * The section a path belongs to: `/moderation/42` is in the moderation. Null outside every
+ * section (a mistyped address).
+ */
+export function sectionOfPath(pathname: string): AdminSection | null {
+ if (pathname === sectionPaths.dashboard) {
+ return 'dashboard';
+ }
+ const entries = Object.entries(sectionPaths) as [AdminSection, string][];
+ const match = entries.find(
+ ([section, path]) =>
+ section !== 'dashboard' && (pathname === path || pathname.startsWith(`${path}/`)),
+ );
+ return match ? match[0] : null;
+}
diff --git a/apps/admin/src/config/public-env.ts b/apps/admin/src/config/public-env.ts
new file mode 100644
index 00000000..c1a2b3bc
--- /dev/null
+++ b/apps/admin/src/config/public-env.ts
@@ -0,0 +1,13 @@
+import { parseEnv } from './env';
+
+/**
+ * The environment as the browser sees it. Next.js replaces each `process.env.NEXT_PUBLIC_…`
+ * written in full at build time, so the variables are named one by one, never read in a loop.
+ */
+export const publicEnv = parseEnv({
+ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
+ NEXT_PUBLIC_FIREBASE_API_KEY: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
+ NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
+ NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
+ NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL,
+});
diff --git a/apps/admin/src/containers/SecondFactorContainer.tsx b/apps/admin/src/containers/SecondFactorContainer.tsx
new file mode 100644
index 00000000..43728d6c
--- /dev/null
+++ b/apps/admin/src/containers/SecondFactorContainer.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { SecondFactorChallenge } from '@/components/sign-in/SecondFactorChallenge';
+import { SignInLayout } from '@/components/sign-in/SignInLayout';
+import { SignInNotices } from '@/components/sign-in/SignInNotices';
+import { TotpEnrollment } from '@/components/sign-in/TotpEnrollment';
+import { useSecondFactor } from '@/hooks/useSecondFactor';
+
+/**
+ * The second step of a sign-in to the admin: the code of the authenticator app, or its first
+ * set-up for a member of the team who has none yet (D-087).
+ */
+export function SecondFactorContainer({ isEnrolling }: { isEnrolling: boolean }) {
+ const factor = useSecondFactor(isEnrolling);
+ const notices = (
+
+ );
+ return (
+
+ {isEnrolling ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/containers/ShellContainer.tsx b/apps/admin/src/containers/ShellContainer.tsx
new file mode 100644
index 00000000..9a65950f
--- /dev/null
+++ b/apps/admin/src/containers/ShellContainer.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { AccountMenu } from '@/components/shell/AccountMenu';
+import { AdminShell } from '@/components/shell/AdminShell';
+import { AdminSidebar, type SidebarEntry } from '@/components/shell/AdminSidebar';
+import { PageMessage } from '@/components/states/PageMessage';
+import { navigationFor, sectionOfPath, sectionPaths } from '@/config/navigation';
+import { useAdminCounters } from '@/hooks/useAdminCounters';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
+import { useSession } from '@/hooks/useSession';
+import { counterLabel } from '@/lib/counter-label';
+import { signInPathFor } from '@/lib/return-path';
+import { UnavailableMessage } from '@/components/states/UnavailableMessage';
+import { messages } from '@/messages/fr';
+import {
+ countedAdminSections,
+ type AdminCounters,
+ type AdminSection,
+ type CountedAdminSection,
+} from '@widoo/shared';
+import { TooltipProvider } from '@widoo/ui';
+import { usePathname, useRouter } from 'next/navigation';
+import { useEffect, useState, type ReactNode } from 'react';
+
+// Wiki Ecrans › Admin web, « Navigation »: the full menu from 1 280 px, its icons below.
+const fullMenuQuery = '(min-width: 1280px)';
+
+const isCounted = (section: AdminSection): section is CountedAdminSection =>
+ countedAdminSections.some((counted) => counted === section);
+
+/** The entry of a section, with its counter when something waits there. */
+function entryOf(section: AdminSection, counters: AdminCounters): SidebarEntry {
+ const counter = isCounted(section) ? counters[section] : undefined;
+ return {
+ section,
+ href: sectionPaths[section],
+ label: messages.sections[section],
+ counter:
+ isCounted(section) && counter && counter.count > 0
+ ? {
+ count: counter.count,
+ isLate: counter.lateCount > 0,
+ label: counterLabel(section, counter),
+ }
+ : undefined,
+ };
+}
+
+/**
+ * The shell around every page of the admin: only for a member of the team, with the navigation
+ * of its role. Anyone else is sent to the sign-in page, which remembers the page asked for.
+ */
+export function ShellContainer({ children }: { children: ReactNode }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const { session, signOut, retry } = useSession();
+ const hasRoomForFullMenu = useMediaQuery(fullMenuQuery);
+ const [isMenuReduced, setIsMenuReduced] = useState(false);
+ const counters = useAdminCounters(session.status === 'signedIn');
+
+ useEffect(() => {
+ // Signed out, or stopped at the second factor: both are finished on the sign-in page.
+ const isAtTheDoor =
+ session.status === 'signedOut' ||
+ session.status === 'secondFactorChallenge' ||
+ session.status === 'secondFactorEnrollment';
+ if (isAtTheDoor) {
+ router.replace(signInPathFor(`${window.location.pathname}${window.location.search}`));
+ }
+ }, [session.status, router]);
+
+ if (session.status === 'unavailable') {
+ return void signOut()} />;
+ }
+ if (session.status !== 'signedIn') {
+ return ;
+ }
+
+ const { member } = session;
+ const activeSection = sectionOfPath(pathname);
+ const groups = navigationFor(member.role).map((group) => ({
+ id: group.id,
+ label: messages.shell.groups[group.id],
+ entries: group.sections.map((section) => entryOf(section, counters)),
+ }));
+
+ return (
+
+ setIsMenuReduced((isReduced) => !isReduced) : undefined
+ }
+ />
+ }
+ account={
+ void signOut()}
+ />
+ }
+ >
+ {children}
+
+
+ );
+}
diff --git a/apps/admin/src/containers/SignInContainer.tsx b/apps/admin/src/containers/SignInContainer.tsx
new file mode 100644
index 00000000..02b910d5
--- /dev/null
+++ b/apps/admin/src/containers/SignInContainer.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { LinkSentPanel } from '@/components/sign-in/LinkSentPanel';
+import { SignInForm } from '@/components/sign-in/SignInForm';
+import { SignInLayout } from '@/components/sign-in/SignInLayout';
+import { SignInNotices } from '@/components/sign-in/SignInNotices';
+import { PageMessage } from '@/components/states/PageMessage';
+import { UnavailableMessage } from '@/components/states/UnavailableMessage';
+import { SecondFactorContainer } from '@/containers/SecondFactorContainer';
+import { useSession } from '@/hooks/useSession';
+import { useSignIn } from '@/hooks/useSignIn';
+import { returnParam, returnSection, safeReturnPath } from '@/lib/return-path';
+import { messages } from '@/messages/fr';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { useEffect } from 'react';
+
+/** A-01: the sign-in page, then the page asked for once a member of the team is in. */
+export function SignInContainer() {
+ const router = useRouter();
+ const returnPath = safeReturnPath(useSearchParams().get(returnParam));
+ const { retry, signOut, hasExpired } = useSession();
+ const signIn = useSignIn(returnPath);
+ const { session } = signIn;
+
+ useEffect(() => {
+ if (session.status === 'signedIn') {
+ router.replace(returnPath);
+ }
+ }, [session.status, returnPath, router]);
+
+ if (session.status === 'unavailable') {
+ return void signOut()} />;
+ }
+ if (session.status === 'secondFactorChallenge' || session.status === 'secondFactorEnrollment') {
+ return ;
+ }
+ if (session.status !== 'signedOut') {
+ return ;
+ }
+
+ const notices = (
+
+ );
+ return (
+
+ {signIn.isLinkSent ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/hooks/useAdminCounters.ts b/apps/admin/src/hooks/useAdminCounters.ts
new file mode 100644
index 00000000..823f30a1
--- /dev/null
+++ b/apps/admin/src/hooks/useAdminCounters.ts
@@ -0,0 +1,34 @@
+import { getSessionClients } from '@/lib/auth/clients';
+import type { AdminCounters } from '@widoo/shared';
+import { useEffect, useState } from 'react';
+
+// Light counters: read again every minute while the admin stays open.
+const refreshMs = 60_000;
+
+/**
+ * The counters of the navigation, for the role of the member signed in. Empty until the API
+ * answers, and when it refuses or fails: the navigation then shows no counter.
+ */
+export function useAdminCounters(isSignedIn: boolean): AdminCounters {
+ const [counters, setCounters] = useState({});
+
+ useEffect(() => {
+ if (!isSignedIn) {
+ return;
+ }
+ const controller = new AbortController();
+ const read = () =>
+ getSessionClients()
+ .api.getAdminCounters(controller.signal)
+ .then(setCounters)
+ .catch(() => undefined);
+ void read();
+ const timer = window.setInterval(read, refreshMs);
+ return () => {
+ controller.abort();
+ window.clearInterval(timer);
+ };
+ }, [isSignedIn]);
+
+ return isSignedIn ? counters : {};
+}
diff --git a/apps/admin/src/hooks/useCountdown.ts b/apps/admin/src/hooks/useCountdown.ts
new file mode 100644
index 00000000..c5651b5e
--- /dev/null
+++ b/apps/admin/src/hooks/useCountdown.ts
@@ -0,0 +1,18 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Seconds left before something is allowed again, counted down from `start()`; 0 once allowed.
+ */
+export function useCountdown(seconds: number): { secondsLeft: number; start: () => void } {
+ const [secondsLeft, setSecondsLeft] = useState(0);
+
+ useEffect(() => {
+ if (secondsLeft === 0) {
+ return;
+ }
+ const timer = window.setTimeout(() => setSecondsLeft((left) => left - 1), 1000);
+ return () => window.clearTimeout(timer);
+ }, [secondsLeft]);
+
+ return { secondsLeft, start: () => setSecondsLeft(seconds) };
+}
diff --git a/apps/admin/src/hooks/useListQuery.ts b/apps/admin/src/hooks/useListQuery.ts
new file mode 100644
index 00000000..65997d93
--- /dev/null
+++ b/apps/admin/src/hooks/useListQuery.ts
@@ -0,0 +1,42 @@
+import {
+ parseListQuery,
+ serializeListQuery,
+ toggledSort,
+ type ListQuery,
+ type ListQueryOptions,
+} from '@/lib/list-query';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+/**
+ * The state of a list, read from and written to its address: search, filters, sort and page.
+ * Changing the search, a filter or the sort goes back to the first page.
+ */
+export function useListQuery(
+ options: ListQueryOptions,
+) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const query = parseListQuery(useSearchParams(), options);
+
+ const show = (next: ListQuery) => {
+ const address = serializeListQuery(next, options);
+ router.replace(address ? `${pathname}?${address}` : pathname, { scroll: false });
+ };
+ const fromFirstPage = (changes: Partial>) =>
+ show({ ...query, ...changes, cursor: null });
+
+ return {
+ query,
+ handleSearch: (search: string) => fromFirstPage({ search }),
+ /** Sets a filter to `value`, or removes it when it already has this value. */
+ handleFilter: (filter: Filter, value: string) =>
+ fromFirstPage({
+ filters: {
+ ...query.filters,
+ [filter]: query.filters[filter] === value ? undefined : value,
+ },
+ }),
+ handleSort: (column: Column) => fromFirstPage({ sort: toggledSort(query.sort, column) }),
+ handlePage: (cursor: string | null) => show({ ...query, cursor }),
+ };
+}
diff --git a/apps/admin/src/hooks/useMediaQuery.ts b/apps/admin/src/hooks/useMediaQuery.ts
new file mode 100644
index 00000000..37ecbb64
--- /dev/null
+++ b/apps/admin/src/hooks/useMediaQuery.ts
@@ -0,0 +1,18 @@
+import { useCallback, useSyncExternalStore } from 'react';
+
+/** True while the window matches `query`; false while the page is rendered on the server. */
+export function useMediaQuery(query: string): boolean {
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ const list = window.matchMedia(query);
+ list.addEventListener('change', onChange);
+ return () => list.removeEventListener('change', onChange);
+ },
+ [query],
+ );
+ return useSyncExternalStore(
+ subscribe,
+ () => window.matchMedia(query).matches,
+ () => false,
+ );
+}
diff --git a/apps/admin/src/hooks/useSecondFactor.ts b/apps/admin/src/hooks/useSecondFactor.ts
new file mode 100644
index 00000000..a26c6d42
--- /dev/null
+++ b/apps/admin/src/hooks/useSecondFactor.ts
@@ -0,0 +1,57 @@
+import { SignInError, type SignInFailure } from '@/lib/auth/auth-client';
+import { toDataURL } from 'qrcode';
+import { useEffect, useRef, useState } from 'react';
+import { useSession } from './useSession';
+
+const failureOf = (error: unknown): SignInFailure =>
+ error instanceof SignInError ? error.failure : 'generic';
+
+/**
+ * The second factor of the admin (D-087): typing the code of the authenticator app to finish a
+ * sign-in, and, for a first sign-in, preparing the QR code of the account then adding the factor.
+ * `isEnrolling` says which of the two the screen is doing.
+ */
+export function useSecondFactor(isEnrolling: boolean) {
+ const { verifySecondFactor, startTotpEnrollment, finishTotpEnrollment, signOut } = useSession();
+ const [code, setCode] = useState('');
+ const [failure, setFailure] = useState(null);
+ const [isBusy, setIsBusy] = useState(false);
+ const [setup, setSetup] = useState<{ qrCodeImage: string; secretKey: string } | null>(null);
+ // One secret per visit of the screen: React runs effects twice in development.
+ const hasStarted = useRef(false);
+
+ useEffect(() => {
+ if (!isEnrolling || hasStarted.current) {
+ return;
+ }
+ hasStarted.current = true;
+ startTotpEnrollment()
+ .then(async ({ qrCodeUri, secretKey }) =>
+ setSetup({ qrCodeImage: await toDataURL(qrCodeUri, { margin: 1, width: 352 }), secretKey }),
+ )
+ .catch((error: unknown) => setFailure(failureOf(error)));
+ }, [isEnrolling, startTotpEnrollment]);
+
+ const handleSubmit = async () => {
+ setFailure(null);
+ setIsBusy(true);
+ try {
+ await (isEnrolling ? finishTotpEnrollment(code) : verifySecondFactor(code));
+ } catch (error) {
+ setFailure(failureOf(error));
+ setCode('');
+ } finally {
+ setIsBusy(false);
+ }
+ };
+
+ return {
+ code,
+ failure,
+ isBusy,
+ setup,
+ handleCodeChange: setCode,
+ handleSubmit: () => void handleSubmit(),
+ handleOtherAccount: () => void signOut(),
+ };
+}
diff --git a/apps/admin/src/hooks/useSession.ts b/apps/admin/src/hooks/useSession.ts
new file mode 100644
index 00000000..bad8bcb6
--- /dev/null
+++ b/apps/admin/src/hooks/useSession.ts
@@ -0,0 +1,11 @@
+import { SessionContext, type SessionContextValue } from '@/lib/auth/SessionProvider';
+import { use } from 'react';
+
+/** The session of the admin and its actions; only under `SessionProvider`. */
+export function useSession(): SessionContextValue {
+ const value = use(SessionContext);
+ if (!value) {
+ throw new Error('useSession must be used under SessionProvider');
+ }
+ return value;
+}
diff --git a/apps/admin/src/hooks/useSignIn.ts b/apps/admin/src/hooks/useSignIn.ts
new file mode 100644
index 00000000..cf2e73fc
--- /dev/null
+++ b/apps/admin/src/hooks/useSignIn.ts
@@ -0,0 +1,80 @@
+import { SignInError, type SignInFailure } from '@/lib/auth/auth-client';
+import { signInPathFor } from '@/lib/return-path';
+import { useRouter } from 'next/navigation';
+import { useEffect, useRef, useState } from 'react';
+import { useCountdown } from './useCountdown';
+import { useSession } from './useSession';
+
+// Wiki Ecrans › A-01, « lien envoyé »: the link may be sent again after 60 s.
+const resendDelaySeconds = 60;
+
+const failureOf = (error: unknown): SignInFailure =>
+ error instanceof SignInError ? error.failure : 'generic';
+
+/**
+ * The sign-in page (A-01): the two ways in, the link that comes back from the mailbox, and what
+ * to tell the person when one of them fails. `returnPath` is the page to open afterwards.
+ */
+export function useSignIn(returnPath: string) {
+ const { session, signInWithGoogle, sendSignInLink, completeSignInLink } = useSession();
+ const [email, setEmail] = useState('');
+ const [isLinkSent, setIsLinkSent] = useState(false);
+ const [failure, setFailure] = useState(null);
+ const [isBusy, setIsBusy] = useState(false);
+ const { secondsLeft, start: startResendDelay } = useCountdown(resendDelaySeconds);
+
+ const router = useRouter();
+ // A link only serves once: React runs effects twice in development.
+ const hasReadLink = useRef(false);
+
+ // The page opened from the link of the e-mail: finish signing in, then drop the code of the
+ // link from the address, so that reloading the page does not present it again.
+ useEffect(() => {
+ if (hasReadLink.current) {
+ return;
+ }
+ hasReadLink.current = true;
+ completeSignInLink(window.location.href)
+ .then((outcome) => outcome !== 'notALink' && router.replace(signInPathFor(returnPath)))
+ .catch((error: unknown) => {
+ setFailure(failureOf(error));
+ router.replace(signInPathFor(returnPath));
+ });
+ }, [completeSignInLink, returnPath, router]);
+
+ const run = async (attempt: () => Promise) => {
+ setFailure(null);
+ setIsBusy(true);
+ try {
+ await attempt();
+ } catch (error) {
+ setFailure(failureOf(error));
+ } finally {
+ setIsBusy(false);
+ }
+ };
+
+ const sendLink = () =>
+ run(async () => {
+ await sendSignInLink(email.trim(), returnPath);
+ setIsLinkSent(true);
+ startResendDelay();
+ });
+
+ return {
+ session,
+ email,
+ isLinkSent,
+ failure,
+ isBusy,
+ secondsLeft,
+ handleEmailChange: setEmail,
+ handleGoogle: () => run(signInWithGoogle),
+ handleSendLink: sendLink,
+ handleResend: sendLink,
+ handleOtherAddress: () => {
+ setFailure(null);
+ setIsLinkSent(false);
+ },
+ };
+}
diff --git a/apps/admin/src/index.ts b/apps/admin/src/index.ts
deleted file mode 100644
index cb0ff5c3..00000000
--- a/apps/admin/src/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export {};
diff --git a/apps/admin/src/lib/auth/SessionProvider.tsx b/apps/admin/src/lib/auth/SessionProvider.tsx
new file mode 100644
index 00000000..a1376aa8
--- /dev/null
+++ b/apps/admin/src/lib/auth/SessionProvider.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
+import { publicEnv } from '@/config/public-env';
+import type { SignInOutcome, TotpEnrollment } from './auth-client';
+import { getSessionClients, onSessionRefused } from './clients';
+import { readSession, requireSecondFactor, type Session, type SignInRefusal } from './session';
+
+export type SessionContextValue = {
+ session: Session;
+ /** The API ended the session of the member signed in (12 hours, D-087): A-01 says so. */
+ hasExpired: boolean;
+ /** Opens the Google window, and says how the attempt ended. Throws a `SignInError`. */
+ signInWithGoogle: () => Promise;
+ sendSignInLink: (email: string, returnPath: string) => Promise;
+ /** Signs in when `url` is a sign-in link; `notALink` otherwise. Throws a `SignInError`. */
+ completeSignInLink: (url: string) => Promise;
+ /** Finishes a sign-in with the code of the authenticator app. Throws a `SignInError`. */
+ verifySecondFactor: (code: string) => Promise;
+ /** Prepares the second factor of the member signed in. Throws a `SignInError`. */
+ startTotpEnrollment: () => Promise;
+ /** Adds the second factor, then lets the member in. Throws a `SignInError`. */
+ finishTotpEnrollment: (code: string) => Promise;
+ signOut: () => Promise;
+ /** Reads the account again after the API did not answer. */
+ retry: () => void;
+};
+
+// The local Auth emulator has no TOTP: the second factor is only required with a real project,
+// as on the side of the API.
+const isSecondFactorRequired = publicEnv.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL === undefined;
+
+export const SessionContext = createContext(null);
+
+/**
+ * Holds the session of the admin: who is signed in with the identity provider, and what the API
+ * says of this account. An account without a team role is signed out at once, with the reason
+ * kept for the sign-in page.
+ */
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const [session, setSession] = useState({ status: 'loading' });
+ const [attempt, setAttempt] = useState(0);
+ // Why the last account was refused: read when the provider reports the sign-out that follows.
+ const refusal = useRef(null);
+ // A sign-in waits for its code: nobody is signed in yet, but the person is not signed out.
+ const isChallengePending = useRef(false);
+ const [hasExpired, setHasExpired] = useState(false);
+ const status = useRef(session.status);
+
+ useEffect(() => {
+ status.current = session.status;
+ });
+
+ // The API refuses the token of a member who was in: the session is over, back to A-01.
+ useEffect(() => {
+ onSessionRefused(() => {
+ if (status.current !== 'signedIn') {
+ return;
+ }
+ setHasExpired(true);
+ void getSessionClients().auth.signOut();
+ });
+ return () => onSessionRefused(undefined);
+ }, []);
+
+ useEffect(() => {
+ const { auth, api } = getSessionClients();
+ let isCurrent = true;
+ const unsubscribe = auth.onUserChanged((uid) => {
+ if (uid === null) {
+ setSession(
+ isChallengePending.current
+ ? { status: 'secondFactorChallenge' }
+ : { status: 'signedOut', refusal: refusal.current },
+ );
+ return;
+ }
+ isChallengePending.current = false;
+ setSession({ status: 'loading' });
+ void readSession(api).then(async (next) => {
+ if (!isCurrent) {
+ return;
+ }
+ if (next.status === 'signedOut') {
+ refusal.current = next.refusal;
+ await auth.signOut();
+ return;
+ }
+ setSession(
+ requireSecondFactor(next, {
+ isRequired: isSecondFactorRequired,
+ isEnrolled: auth.hasSecondFactor(),
+ }),
+ );
+ });
+ });
+ return () => {
+ isCurrent = false;
+ unsubscribe();
+ };
+ }, [attempt]);
+
+ const startAttempt = useCallback(() => {
+ refusal.current = null;
+ setHasExpired(false);
+ setSession((current) =>
+ current.status === 'signedOut' ? { status: 'signedOut', refusal: null } : current,
+ );
+ }, []);
+
+ /** Shows the code screen when a sign-in asks for the second factor. */
+ const followUp = useCallback((outcome: Outcome): Outcome => {
+ if (outcome === 'secondFactorRequired') {
+ isChallengePending.current = true;
+ setSession({ status: 'secondFactorChallenge' });
+ }
+ return outcome;
+ }, []);
+
+ const value = useMemo(() => {
+ const auth = () => getSessionClients().auth;
+ return {
+ session,
+ hasExpired,
+ signInWithGoogle: async () => {
+ startAttempt();
+ return followUp(await auth().signInWithGoogle());
+ },
+ sendSignInLink: (email, returnPath) => {
+ startAttempt();
+ return auth().sendSignInLink(email, returnPath);
+ },
+ completeSignInLink: async (url) => followUp(await auth().completeSignInLink(url)),
+ verifySecondFactor: (code) => auth().verifySecondFactor(code),
+ startTotpEnrollment: () => auth().startTotpEnrollment(),
+ finishTotpEnrollment: async (code) => {
+ await auth().finishTotpEnrollment(code);
+ // Read the session again: the account now has its second factor.
+ setAttempt((count) => count + 1);
+ },
+ signOut: () => {
+ isChallengePending.current = false;
+ setHasExpired(false);
+ setSession((current) =>
+ current.status === 'secondFactorChallenge'
+ ? { status: 'signedOut', refusal: null }
+ : current,
+ );
+ return auth().signOut();
+ },
+ retry: () => setAttempt((count) => count + 1),
+ };
+ }, [session, hasExpired, startAttempt, followUp]);
+
+ return {children};
+}
diff --git a/apps/admin/src/lib/auth/auth-client.ts b/apps/admin/src/lib/auth/auth-client.ts
new file mode 100644
index 00000000..365396a0
--- /dev/null
+++ b/apps/admin/src/lib/auth/auth-client.ts
@@ -0,0 +1,62 @@
+import type { GetToken } from '@widoo/api-client';
+
+/** Why a sign-in attempt failed, as far as the page can tell the person. */
+export type SignInFailure =
+ | 'popupBlocked'
+ | 'linkInvalid'
+ | 'linkOtherDevice'
+ /** The 6-digit code is wrong or already expired. */
+ | 'codeInvalid'
+ /** Too long since the sign-in to change the second factor: sign in again. */
+ | 'recentSignInRequired'
+ | 'generic';
+
+/**
+ * How a sign-in attempt ended: signed in, waiting for the code of the authenticator app (the
+ * account has a second factor), or given up by the person.
+ */
+export type SignInOutcome = 'signedIn' | 'secondFactorRequired' | 'cancelled';
+
+/** What the person needs to add the account to an authenticator app. */
+export type TotpEnrollment = {
+ /** Address of the `otpauth://` kind, to show as a QR code. */
+ qrCodeUri: string;
+ /** The same secret as text, to type when the QR code cannot be scanned. */
+ secretKey: string;
+};
+
+export class SignInError extends Error {
+ constructor(
+ readonly failure: SignInFailure,
+ options?: ErrorOptions,
+ ) {
+ super(`Sign-in failed: ${failure}`, options);
+ this.name = 'SignInError';
+ }
+}
+
+/**
+ * What the admin needs from the identity provider. Firebase at runtime (`firebase-client.ts`);
+ * the session and the screens only know this interface.
+ */
+export interface AuthClient {
+ /** Calls `listener` now and at every change: an identifier when someone is signed in. */
+ onUserChanged(listener: (uid: string | null) => void): () => void;
+ /** ID token of the signed-in user for the API, null when signed out. */
+ getToken: GetToken;
+ /** Opens the Google window, and says how the attempt ended. */
+ signInWithGoogle(): Promise;
+ /** Sends the sign-in link, which comes back to the sign-in page with `returnPath`. */
+ sendSignInLink(email: string, returnPath: string): Promise;
+ /** Signs in when `url` is a sign-in link; `notALink` when it is not one. */
+ completeSignInLink(url: string): Promise;
+ /** Finishes a sign-in that waits for the 6-digit code of the authenticator app. */
+ verifySecondFactor(code: string): Promise;
+ /** True when the signed-in account has a second factor. */
+ hasSecondFactor(): boolean;
+ /** Prepares a second factor for the signed-in account: the secret to add to the app. */
+ startTotpEnrollment(): Promise;
+ /** Adds the second factor once the app gives a valid code for the secret just prepared. */
+ finishTotpEnrollment(code: string): Promise;
+ signOut(): Promise;
+}
diff --git a/apps/admin/src/lib/auth/clients.ts b/apps/admin/src/lib/auth/clients.ts
new file mode 100644
index 00000000..b1845e0f
--- /dev/null
+++ b/apps/admin/src/lib/auth/clients.ts
@@ -0,0 +1,39 @@
+import { publicEnv } from '@/config/public-env';
+import { createApiClient, type ApiClient } from '@widoo/api-client';
+import type { AuthClient } from './auth-client';
+import { createFirebaseAuthClient } from './firebase-client';
+
+type SessionClients = { auth: AuthClient; api: ApiClient };
+
+let clients: SessionClients | undefined;
+let sessionRefusedListener: (() => void) | undefined;
+
+/**
+ * Who to tell when the API ends the session of the member (401 with a fresh token): the session
+ * provider, which signs out and sends back to A-01. Undefined to stop listening.
+ */
+export function onSessionRefused(listener: (() => void) | undefined): void {
+ sessionRefusedListener = listener;
+}
+
+/**
+ * The identity provider and the API client of the admin, created on first use: in the browser
+ * only (effects and event handlers), never while a page is rendered on the server.
+ */
+export function getSessionClients(): SessionClients {
+ if (!clients) {
+ const auth = createFirebaseAuthClient({
+ apiKey: publicEnv.NEXT_PUBLIC_FIREBASE_API_KEY,
+ authDomain: publicEnv.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
+ projectId: publicEnv.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
+ emulatorUrl: publicEnv.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL,
+ });
+ const api = createApiClient({
+ baseUrl: publicEnv.NEXT_PUBLIC_API_URL,
+ getToken: auth.getToken,
+ onUnauthorized: () => sessionRefusedListener?.(),
+ });
+ clients = { auth, api };
+ }
+ return clients;
+}
diff --git a/apps/admin/src/lib/auth/firebase-client.ts b/apps/admin/src/lib/auth/firebase-client.ts
new file mode 100644
index 00000000..6807b485
--- /dev/null
+++ b/apps/admin/src/lib/auth/firebase-client.ts
@@ -0,0 +1,190 @@
+import { returnParam } from '@/lib/return-path';
+import { FirebaseError, initializeApp } from '@firebase/app';
+import {
+ connectAuthEmulator,
+ getAuth,
+ getMultiFactorResolver,
+ GoogleAuthProvider,
+ isSignInWithEmailLink,
+ multiFactor,
+ onAuthStateChanged,
+ sendSignInLinkToEmail,
+ signInWithEmailLink,
+ signInWithPopup,
+ signOut,
+ TotpMultiFactorGenerator,
+ type MultiFactorError,
+ type MultiFactorResolver,
+ type TotpSecret,
+} from '@firebase/auth';
+import {
+ SignInError,
+ type AuthClient,
+ type SignInFailure,
+ type SignInOutcome,
+} from './auth-client';
+
+export type FirebaseSettings = {
+ apiKey: string;
+ authDomain: string;
+ projectId: string;
+ /** Local Auth emulator (`http://127.0.0.1:9099`); never set on staging or production. */
+ emulatorUrl?: string;
+};
+
+// The address the link was asked for, kept on this device only: Firebase asks for it again when
+// the link comes back, so that a link opened by someone else signs nobody in.
+const pendingEmailKey = 'widoo.admin.signInEmail';
+
+const closedByUser = new Set(['auth/popup-closed-by-user', 'auth/cancelled-popup-request']);
+
+const failures: Record = {
+ 'auth/popup-blocked': 'popupBlocked',
+ 'auth/invalid-action-code': 'linkInvalid',
+ 'auth/expired-action-code': 'linkInvalid',
+ 'auth/invalid-email': 'linkInvalid',
+ 'auth/invalid-verification-code': 'codeInvalid',
+ 'auth/code-expired': 'codeInvalid',
+ 'auth/requires-recent-login': 'recentSignInRequired',
+};
+
+const secondFactorRequired = 'auth/multi-factor-auth-required';
+// Name of the account in the authenticator app, and of the second factor in Firebase.
+const issuer = 'Widoo Admin';
+const factorName = 'Application';
+
+function signInError(error: unknown): SignInError {
+ const failure = error instanceof FirebaseError ? failures[error.code] : undefined;
+ return new SignInError(failure ?? 'generic', { cause: error });
+}
+
+/** The identity provider of the admin: Firebase Authentication, Google and e-mail link. */
+export function createFirebaseAuthClient(settings: FirebaseSettings): AuthClient {
+ const { emulatorUrl, ...options } = settings;
+ const auth = getAuth(initializeApp(options));
+ auth.languageCode = 'fr';
+ if (emulatorUrl) {
+ connectAuthEmulator(auth, emulatorUrl, { disableWarnings: true });
+ }
+
+ // The sign-in waiting for its code, and the secret of an enrollment being set up: both only
+ // live between two steps of the same screen.
+ let pendingSignIn: MultiFactorResolver | null = null;
+ let pendingSecret: TotpSecret | null = null;
+
+ /** Runs a sign-in, and keeps what Firebase needs when it asks for the second factor. */
+ async function attempt(signIn: () => Promise): Promise {
+ try {
+ await signIn();
+ return 'signedIn';
+ } catch (error) {
+ if (error instanceof FirebaseError && closedByUser.has(error.code)) {
+ return 'cancelled';
+ }
+ if (error instanceof FirebaseError && error.code === secondFactorRequired) {
+ pendingSignIn = getMultiFactorResolver(auth, error as MultiFactorError);
+ return 'secondFactorRequired';
+ }
+ throw signInError(error);
+ }
+ }
+
+ function signedInUser() {
+ if (!auth.currentUser) {
+ throw new SignInError('recentSignInRequired');
+ }
+ return auth.currentUser;
+ }
+
+ return {
+ onUserChanged: (listener) => onAuthStateChanged(auth, (user) => listener(user?.uid ?? null)),
+
+ getToken: async ({ forceRefresh }) =>
+ (await auth.currentUser?.getIdToken(forceRefresh)) ?? null,
+
+ signInWithGoogle() {
+ const provider = new GoogleAuthProvider();
+ // Always the account chooser: a refused account must be able to try another one.
+ provider.setCustomParameters({ prompt: 'select_account' });
+ return attempt(() => signInWithPopup(auth, provider));
+ },
+
+ async sendSignInLink(email, returnPath) {
+ const url = new URL('/connexion', window.location.origin);
+ url.searchParams.set(returnParam, returnPath);
+ try {
+ await sendSignInLinkToEmail(auth, email, { url: url.href, handleCodeInApp: true });
+ } catch (error) {
+ throw signInError(error);
+ }
+ window.localStorage.setItem(pendingEmailKey, email);
+ },
+
+ async completeSignInLink(url) {
+ if (!isSignInWithEmailLink(auth, url)) {
+ return 'notALink';
+ }
+ const email = window.localStorage.getItem(pendingEmailKey);
+ if (!email) {
+ throw new SignInError('linkOtherDevice');
+ }
+ const outcome = await attempt(() => signInWithEmailLink(auth, email, url));
+ window.localStorage.removeItem(pendingEmailKey);
+ return outcome;
+ },
+
+ async verifySecondFactor(code) {
+ const hint = pendingSignIn?.hints.find(
+ (factor) => factor.factorId === TotpMultiFactorGenerator.FACTOR_ID,
+ );
+ if (!pendingSignIn || !hint) {
+ throw new SignInError('recentSignInRequired');
+ }
+ try {
+ await pendingSignIn.resolveSignIn(
+ TotpMultiFactorGenerator.assertionForSignIn(hint.uid, code),
+ );
+ } catch (error) {
+ throw signInError(error);
+ }
+ pendingSignIn = null;
+ },
+
+ hasSecondFactor: () =>
+ auth.currentUser !== null && multiFactor(auth.currentUser).enrolledFactors.length > 0,
+
+ async startTotpEnrollment() {
+ const user = signedInUser();
+ try {
+ const session = await multiFactor(user).getSession();
+ pendingSecret = await TotpMultiFactorGenerator.generateSecret(session);
+ } catch (error) {
+ throw signInError(error);
+ }
+ return {
+ secretKey: pendingSecret.secretKey,
+ qrCodeUri: pendingSecret.generateQrCodeUrl(user.email ?? issuer, issuer),
+ };
+ },
+
+ async finishTotpEnrollment(code) {
+ const user = signedInUser();
+ if (!pendingSecret) {
+ throw new SignInError('recentSignInRequired');
+ }
+ try {
+ await multiFactor(user).enroll(
+ TotpMultiFactorGenerator.assertionForEnrollment(pendingSecret, code),
+ factorName,
+ );
+ // The API reads the second factor in the token: ask for one that carries it.
+ await user.getIdToken(true);
+ } catch (error) {
+ throw signInError(error);
+ }
+ pendingSecret = null;
+ },
+
+ signOut: () => signOut(auth),
+ };
+}
diff --git a/apps/admin/src/lib/auth/session.test.ts b/apps/admin/src/lib/auth/session.test.ts
new file mode 100644
index 00000000..a64e80b5
--- /dev/null
+++ b/apps/admin/src/lib/auth/session.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import { ApiRequestError } from '@widoo/api-client';
+import type { Me } from '@widoo/shared';
+import { readSession, requireSecondFactor, sessionOfAccount, type Session } from './session';
+
+const account = { firstName: 'Sarah', email: 'sarah@example.com' };
+
+describe('sessionOfAccount', () => {
+ it.each(['editor', 'moderator', 'admin'] as const)('lets a %s in', (role) => {
+ expect(sessionOfAccount({ ...account, role })).toEqual({
+ status: 'signedIn',
+ member: { ...account, role },
+ });
+ });
+
+ it('refuses an account without a team role, with the address used', () => {
+ expect(sessionOfAccount({ ...account, role: 'user' })).toEqual({
+ status: 'signedOut',
+ refusal: { reason: 'noAccess', email: 'sarah@example.com' },
+ });
+ });
+});
+
+describe('readSession', () => {
+ const me = (role: Me['role']) =>
+ ({ ...account, role, id: '0', avatarUrl: null, plan: 'free' }) as unknown as Me;
+
+ it('lets in the member of the team the API describes', async () => {
+ await expect(readSession({ getMe: async () => me('moderator') })).resolves.toMatchObject({
+ status: 'signedIn',
+ member: { role: 'moderator' },
+ });
+ });
+
+ it('refuses the account the API gives no team role', async () => {
+ await expect(readSession({ getMe: async () => me('user') })).resolves.toMatchObject({
+ status: 'signedOut',
+ refusal: { reason: 'noAccess' },
+ });
+ });
+
+ it('refuses the account the API says is suspended', async () => {
+ const getMe = async () => {
+ const body = { code: 'account_suspended' as const, message: 'Account suspended' };
+ throw new ApiRequestError('http', 403, body);
+ };
+ await expect(readSession({ getMe })).resolves.toEqual({
+ status: 'signedOut',
+ refusal: { reason: 'suspended', email: null },
+ });
+ });
+
+ it('refuses a deleted account, which may then try another one', async () => {
+ const getMe = async () => {
+ throw new ApiRequestError('http', 401, { code: 'unauthorized', message: 'Account deleted' });
+ };
+ await expect(readSession({ getMe })).resolves.toEqual({
+ status: 'signedOut',
+ refusal: { reason: 'noAccess', email: null },
+ });
+ });
+
+ it('lets nobody in when the API does not answer', async () => {
+ const getMe = async () => {
+ throw new Error('offline');
+ };
+ await expect(readSession({ getMe })).resolves.toEqual({ status: 'unavailable' });
+ });
+});
+
+describe('requireSecondFactor', () => {
+ const signedIn: Session = { status: 'signedIn', member: { ...account, role: 'moderator' } };
+
+ it('holds a member of the team until the account has a second factor', () => {
+ expect(requireSecondFactor(signedIn, { isRequired: true, isEnrolled: false })).toEqual({
+ status: 'secondFactorEnrollment',
+ member: signedIn.member,
+ });
+ });
+
+ it('lets in a member whose account has one', () => {
+ expect(requireSecondFactor(signedIn, { isRequired: true, isEnrolled: true })).toBe(signedIn);
+ });
+
+ it('asks for nothing where no second factor exists (local Auth emulator)', () => {
+ expect(requireSecondFactor(signedIn, { isRequired: false, isEnrolled: false })).toBe(signedIn);
+ });
+
+ it('asks nothing of an account that is refused anyway', () => {
+ const refused: Session = {
+ status: 'signedOut',
+ refusal: { reason: 'noAccess', email: 'lea@example.com' },
+ };
+ expect(requireSecondFactor(refused, { isRequired: true, isEnrolled: false })).toBe(refused);
+ });
+});
diff --git a/apps/admin/src/lib/auth/session.ts b/apps/admin/src/lib/auth/session.ts
new file mode 100644
index 00000000..26383192
--- /dev/null
+++ b/apps/admin/src/lib/auth/session.ts
@@ -0,0 +1,79 @@
+import { ApiRequestError, isAccountDeleted } from '@widoo/api-client';
+import { isTeamRole, type Me, type TeamRole } from '@widoo/shared';
+
+/** The member of the team who is signed in, as the shell shows it. */
+export type TeamMember = {
+ firstName: string | null;
+ email: string | null;
+ role: TeamRole;
+};
+
+/** Why an account that signed in is not let in (A-01). */
+export type SignInRefusal = {
+ reason: 'noAccess' | 'suspended';
+ /** The address used, shown so that the person can try another account. */
+ email: string | null;
+};
+
+export type Session =
+ /** The browser has not said yet whether someone is signed in, or the account is being read. */
+ | { status: 'loading' }
+ | { status: 'signedOut'; refusal: SignInRefusal | null }
+ /** The account has a second factor: the sign-in waits for the code of the app. */
+ | { status: 'secondFactorChallenge' }
+ /** A member of the team signed in, who must add a second factor before entering (D-087). */
+ | { status: 'secondFactorEnrollment'; member: TeamMember }
+ | { status: 'signedIn'; member: TeamMember }
+ /** Signed in, but the API did not answer: nothing is known about the role. */
+ | { status: 'unavailable' };
+
+/**
+ * The session of an account read from the API (`GET /me`): a member of the team is let in, any
+ * other account is refused. The API checks the role again on every call: this only decides what
+ * the admin shows.
+ */
+export function sessionOfAccount(account: Pick): Session {
+ if (!isTeamRole(account.role)) {
+ return { status: 'signedOut', refusal: { reason: 'noAccess', email: account.email } };
+ }
+ return {
+ status: 'signedIn',
+ member: { firstName: account.firstName, email: account.email, role: account.role },
+ };
+}
+
+/**
+ * Reads the account of the person who just signed in and decides its session. A suspended
+ * account is refused by the API itself (403 `account_suspended`), and so is a deleted one: it has
+ * no access, and the person may try another account. An API that does not answer, or answers
+ * anything unexpected, lets nobody in.
+ */
+export async function readSession(api: { getMe: () => Promise }): Promise {
+ try {
+ return sessionOfAccount(await api.getMe());
+ } catch (error) {
+ if (error instanceof ApiRequestError && error.code === 'account_suspended') {
+ // The API tells nothing of a suspended account, its address included.
+ return { status: 'signedOut', refusal: { reason: 'suspended', email: null } };
+ }
+ if (isAccountDeleted(error)) {
+ return { status: 'signedOut', refusal: { reason: 'noAccess', email: null } };
+ }
+ return { status: 'unavailable' };
+ }
+}
+
+/**
+ * Holds a member of the team at the door until the account has a second factor, when the admin
+ * requires one. Any other session is left as it is: an account outside the team is refused
+ * before being asked for anything.
+ */
+export function requireSecondFactor(
+ session: Session,
+ factor: { isRequired: boolean; isEnrolled: boolean },
+): Session {
+ if (session.status !== 'signedIn' || !factor.isRequired || factor.isEnrolled) {
+ return session;
+ }
+ return { status: 'secondFactorEnrollment', member: session.member };
+}
diff --git a/apps/admin/src/lib/counter-label.test.ts b/apps/admin/src/lib/counter-label.test.ts
new file mode 100644
index 00000000..33fbe53c
--- /dev/null
+++ b/apps/admin/src/lib/counter-label.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { counterLabel } from './counter-label';
+
+describe('counterLabel', () => {
+ it('says what is counted', () => {
+ expect(counterLabel('moderation', { count: 12, lateCount: 0 })).toBe('12 parcours à relire');
+ });
+
+ it('says how many are late, never by the color alone', () => {
+ expect(counterLabel('moderation', { count: 12, lateCount: 2 })).toBe(
+ '12 parcours à relire, dont 2 en retard',
+ );
+ });
+
+ it('agrees in number', () => {
+ expect(counterLabel('support', { count: 1, lateCount: 0 })).toBe('1 message à traiter');
+ expect(counterLabel('support', { count: 4, lateCount: 1 })).toBe(
+ '4 messages à traiter, dont 1 en retard',
+ );
+ });
+});
diff --git a/apps/admin/src/lib/counter-label.ts b/apps/admin/src/lib/counter-label.ts
new file mode 100644
index 00000000..560088fa
--- /dev/null
+++ b/apps/admin/src/lib/counter-label.ts
@@ -0,0 +1,13 @@
+import { messages } from '@/messages/fr';
+import type { AdminCounter, CountedAdminSection } from '@widoo/shared';
+
+/**
+ * A counter said in full: « 12 parcours à relire, dont 2 en retard ». Lateness is never told by
+ * the red alone (wiki Ecrans › Admin web, Accessibilité).
+ */
+export function counterLabel(section: CountedAdminSection, counter: AdminCounter): string {
+ const counted = messages.shell.counted[section](counter.count);
+ return counter.lateCount > 0
+ ? `${counted}${messages.shell.lateAmong(counter.lateCount)}`
+ : counted;
+}
diff --git a/apps/admin/src/lib/dates.test.ts b/apps/admin/src/lib/dates.test.ts
new file mode 100644
index 00000000..8797491e
--- /dev/null
+++ b/apps/admin/src/lib/dates.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+import { formatFullDate, formatRelativeDate } from './dates';
+
+const now = new Date('2026-10-12T10:00:00Z');
+const ago = (ms: number) => new Date(now.getTime() - ms);
+const minute = 60_000;
+const hour = 60 * minute;
+const day = 24 * hour;
+
+describe('formatRelativeDate', () => {
+ it.each([
+ [30_000, "à l'instant"],
+ [12 * minute, 'il y a 12 min'],
+ [6 * hour, 'il y a 6 h'],
+ [30 * hour, 'hier'],
+ [3 * day, 'il y a 3 j'],
+ [6 * day + 23 * hour, 'il y a 6 j'],
+ ])('says %i ms ago as « %s »', (elapsed, expected) => {
+ expect(formatRelativeDate(ago(elapsed), now)).toBe(expected);
+ });
+
+ it('gives the date after 7 days', () => {
+ expect(formatRelativeDate(ago(7 * day), now)).toBe('5 oct. 2026');
+ });
+});
+
+describe('formatFullDate', () => {
+ it('writes the day and the time of Paris', () => {
+ // 06:12 UTC is 08:12 in Paris in October (UTC+2).
+ expect(formatFullDate(new Date('2026-10-08T06:12:00Z'))).toBe('jeudi 8 octobre à 8:12');
+ });
+});
diff --git a/apps/admin/src/lib/dates.ts b/apps/admin/src/lib/dates.ts
new file mode 100644
index 00000000..3985496f
--- /dev/null
+++ b/apps/admin/src/lib/dates.ts
@@ -0,0 +1,45 @@
+// Wiki Admin-Web › Modèles communs: relative up to 7 days, then absolute; Paris time.
+const timeZone = 'Europe/Paris';
+const minute = 60_000;
+const hour = 60 * minute;
+const day = 24 * hour;
+const relativeForDays = 7;
+
+const absoluteDate = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeZone });
+const fullDate = new Intl.DateTimeFormat('fr-FR', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZone,
+});
+
+/**
+ * A date as a list shows it: « à l'instant », « il y a 12 min », « il y a 6 h », « hier »,
+ * « il y a 3 j », then the date (« 9 oct. 2026 ») after 7 days.
+ */
+export function formatRelativeDate(date: Date, now = new Date()): string {
+ const elapsed = now.getTime() - date.getTime();
+ if (elapsed < minute) {
+ return "à l'instant";
+ }
+ if (elapsed < hour) {
+ return `il y a ${Math.floor(elapsed / minute)} min`;
+ }
+ if (elapsed < day) {
+ return `il y a ${Math.floor(elapsed / hour)} h`;
+ }
+ if (elapsed < 2 * day) {
+ return 'hier';
+ }
+ if (elapsed < relativeForDays * day) {
+ return `il y a ${Math.floor(elapsed / day)} j`;
+ }
+ return absoluteDate.format(date);
+}
+
+/** A date in full, as a record shows it: « jeudi 9 octobre à 8:12 », Paris time. */
+export function formatFullDate(date: Date): string {
+ return fullDate.format(date);
+}
diff --git a/apps/admin/src/lib/list-query.test.ts b/apps/admin/src/lib/list-query.test.ts
new file mode 100644
index 00000000..c4361b42
--- /dev/null
+++ b/apps/admin/src/lib/list-query.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+import { parseListQuery, serializeListQuery, toggledSort } from './list-query';
+
+const options = {
+ filters: ['statut', 'ville'],
+ sortableColumns: ['compte', 'recu'],
+ defaultSort: { column: 'recu', direction: 'desc' },
+} as const;
+
+const parse = (address: string) => parseListQuery(new URLSearchParams(address), options);
+
+describe('parseListQuery', () => {
+ it('gives the default list for an empty address', () => {
+ expect(parse('')).toEqual({
+ search: '',
+ filters: {},
+ sort: { column: 'recu', direction: 'desc' },
+ cursor: null,
+ });
+ });
+
+ it('reads the search, the filters, the sort and the page', () => {
+ expect(parse('q=consulat&statut=pending&tri=compte&page=abc')).toEqual({
+ search: 'consulat',
+ filters: { statut: 'pending' },
+ sort: { column: 'compte', direction: 'asc' },
+ cursor: 'abc',
+ });
+ });
+
+ it('reads a descending sort', () => {
+ expect(parse('tri=-compte').sort).toEqual({ column: 'compte', direction: 'desc' });
+ });
+
+ it('drops what the list does not know', () => {
+ const query = parse('role=admin&tri=motdepasse&statut=');
+ expect(query.filters).toEqual({});
+ expect(query.sort).toEqual(options.defaultSort);
+ });
+});
+
+describe('serializeListQuery', () => {
+ it('writes nothing for the default list', () => {
+ expect(serializeListQuery(parse(''), options)).toBe('');
+ });
+
+ it('gives back the address it was read from', () => {
+ const address = 'q=consulat&statut=pending&ville=paris&tri=compte&page=abc';
+ expect(serializeListQuery(parse(address), options)).toBe(address);
+ });
+});
+
+describe('toggledSort', () => {
+ it('reverses the sorted column', () => {
+ expect(toggledSort({ column: 'recu', direction: 'asc' }, 'recu').direction).toBe('desc');
+ expect(toggledSort({ column: 'recu', direction: 'desc' }, 'recu').direction).toBe('asc');
+ });
+
+ it('sorts another column from the first to the last', () => {
+ expect(toggledSort({ column: 'recu', direction: 'desc' }, 'compte')).toEqual({
+ column: 'compte',
+ direction: 'asc',
+ });
+ });
+});
diff --git a/apps/admin/src/lib/list-query.ts b/apps/admin/src/lib/list-query.ts
new file mode 100644
index 00000000..20bd8f8b
--- /dev/null
+++ b/apps/admin/src/lib/list-query.ts
@@ -0,0 +1,88 @@
+import type { SortDirection } from '@widoo/ui';
+
+/** State of a list kept in its address, so that a link shows the same list (wiki Admin-Web). */
+export type ListQuery = {
+ /** Text of the search field; empty without a search. */
+ search: string;
+ /** Active filters, with their value: `{ statut: 'pending', ville: 'paris' }`. */
+ filters: Partial>;
+ sort: { column: Column; direction: SortDirection };
+ /** Cursor of the page shown; null on the first page. */
+ cursor: string | null;
+};
+
+/** What a list accepts in its address: anything else is ignored. */
+export type ListQueryOptions = {
+ filters: readonly Filter[];
+ sortableColumns: readonly Column[];
+ defaultSort: { column: Column; direction: SortDirection };
+};
+
+// Names of the parameters in the address, in French as the paths of the admin.
+const searchParam = 'q';
+const sortParam = 'tri';
+const cursorParam = 'page';
+
+/**
+ * Reads the state of a list from its address. The address is typed by anyone: an unknown filter
+ * or column is dropped, never passed on.
+ */
+export function parseListQuery(
+ params: URLSearchParams,
+ options: ListQueryOptions,
+): ListQuery {
+ const filters: Partial> = {};
+ for (const filter of options.filters) {
+ const value = params.get(filter);
+ if (value) {
+ filters[filter] = value;
+ }
+ }
+ // `-recu` sorts by `recu` from the last to the first.
+ const sortValue = params.get(sortParam) ?? '';
+ const column = options.sortableColumns.find(
+ (sortable) => sortable === sortValue.replace(/^-/, ''),
+ );
+ return {
+ search: params.get(searchParam)?.trim() ?? '',
+ filters,
+ sort: column
+ ? { column, direction: sortValue.startsWith('-') ? 'desc' : 'asc' }
+ : options.defaultSort,
+ cursor: params.get(cursorParam) || null,
+ };
+}
+
+/** The address of a list state, shortest first: the default sort and the first page are left out. */
+export function serializeListQuery(
+ query: ListQuery,
+ options: ListQueryOptions,
+): string {
+ const params = new URLSearchParams();
+ if (query.search) {
+ params.set(searchParam, query.search);
+ }
+ for (const filter of options.filters) {
+ const value = query.filters[filter];
+ if (value) {
+ params.set(filter, value);
+ }
+ }
+ const { column, direction } = query.sort;
+ if (column !== options.defaultSort.column || direction !== options.defaultSort.direction) {
+ params.set(sortParam, direction === 'desc' ? `-${column}` : column);
+ }
+ if (query.cursor) {
+ params.set(cursorParam, query.cursor);
+ }
+ return params.toString();
+}
+
+/** The next sort after a click on a column: its other direction, or ascending on a new column. */
+export function toggledSort(
+ current: { column: Column; direction: SortDirection },
+ column: Column,
+): { column: Column; direction: SortDirection } {
+ const isSameColumn = current.column === column;
+ return { column, direction: isSameColumn && current.direction === 'asc' ? 'desc' : 'asc' };
+}
diff --git a/apps/admin/src/lib/response-headers.test.ts b/apps/admin/src/lib/response-headers.test.ts
new file mode 100644
index 00000000..04242fd5
--- /dev/null
+++ b/apps/admin/src/lib/response-headers.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import { contentSecurityPolicy, responseHeaders } from './response-headers';
+
+const production = {
+ dev: false,
+ apiOrigin: 'https://api.example',
+ authDomain: 'widoo.firebaseapp.com',
+};
+
+const headerValue = (name: string) =>
+ responseHeaders(production).find((header) => header.key === name)?.value;
+
+describe('responseHeaders', () => {
+ it('keeps the admin out of search engines', () => {
+ expect(headerValue('X-Robots-Tag')).toBe('noindex, nofollow');
+ });
+
+ it('forbids every cache', () => {
+ expect(headerValue('Cache-Control')).toBe('no-store');
+ });
+
+ it('forbids framing', () => {
+ expect(headerValue('X-Frame-Options')).toBe('DENY');
+ expect(headerValue('Content-Security-Policy')).toContain("frame-ancestors 'none'");
+ });
+});
+
+describe('contentSecurityPolicy', () => {
+ it('lets the pages call the admin, the API and Firebase Authentication only', () => {
+ expect(contentSecurityPolicy(production)).toContain(
+ "connect-src 'self' https://api.example https://identitytoolkit.googleapis.com https://securetoken.googleapis.com;",
+ );
+ });
+
+ it('frames the sign-in window of the project only', () => {
+ expect(contentSecurityPolicy(production)).toContain('frame-src https://widoo.firebaseapp.com;');
+ });
+
+ it('calls the Auth emulator instead of Firebase when one is set', () => {
+ const policy = contentSecurityPolicy({
+ ...production,
+ dev: true,
+ authEmulatorOrigin: 'http://127.0.0.1:9099',
+ });
+ expect(policy).toContain("connect-src 'self' https://api.example http://127.0.0.1:9099 ws:;");
+ expect(policy).not.toContain('identitytoolkit');
+ });
+
+ it('allows neither eval nor a WebSocket in production', () => {
+ const policy = contentSecurityPolicy(production);
+ expect(policy).not.toContain("'unsafe-eval'");
+ expect(policy).not.toContain('ws:');
+ expect(policy).toContain('upgrade-insecure-requests');
+ });
+
+ it('allows what the hot reload needs in development', () => {
+ const policy = contentSecurityPolicy({ ...production, dev: true });
+ expect(policy).toContain("'unsafe-eval'");
+ expect(policy).toContain('ws:');
+ expect(policy).not.toContain('upgrade-insecure-requests');
+ });
+});
diff --git a/apps/admin/src/lib/response-headers.ts b/apps/admin/src/lib/response-headers.ts
new file mode 100644
index 00000000..7b22ffe3
--- /dev/null
+++ b/apps/admin/src/lib/response-headers.ts
@@ -0,0 +1,70 @@
+// Imported by next.config.ts: relative imports only, no `@/` alias.
+
+type HeaderOptions = {
+ /** `next dev`: React and the hot reload need eval and a WebSocket. */
+ dev: boolean;
+ /** Origin of the API. */
+ apiOrigin: string;
+ /** Host of Firebase Authentication for the project: it serves the Google sign-in window. */
+ authDomain: string;
+ /** Origin of the local Auth emulator, in development only. */
+ authEmulatorOrigin?: string;
+};
+
+// Firebase Authentication from the browser: sign-in and token refresh, and the script of the
+// Google window.
+const firebaseAuthOrigins = [
+ 'https://identitytoolkit.googleapis.com',
+ 'https://securetoken.googleapis.com',
+];
+const googleSignInScripts = 'https://apis.google.com';
+
+/**
+ * Content Security Policy of the admin. The pages are static shells that render their data in the
+ * browser, so no nonce can be generated per request: the inline scripts that Next.js writes in
+ * the page are allowed with 'unsafe-inline', every other source stays closed. Requests go to the
+ * admin itself, to the API and to Firebase Authentication only.
+ */
+export function contentSecurityPolicy(options: HeaderOptions): string {
+ const { dev, apiOrigin, authDomain, authEmulatorOrigin } = options;
+ const authOrigins = authEmulatorOrigin ? [authEmulatorOrigin] : firebaseAuthOrigins;
+ const directives: Record = {
+ 'default-src': ["'self'"],
+ 'script-src': [
+ "'self'",
+ "'unsafe-inline'",
+ googleSignInScripts,
+ ...(dev ? ["'unsafe-eval'"] : []),
+ ],
+ 'style-src': ["'self'", "'unsafe-inline'"],
+ 'img-src': ["'self'", 'data:', 'blob:'],
+ 'font-src': ["'self'"],
+ 'connect-src': ["'self'", apiOrigin, ...authOrigins, ...(dev ? ['ws:'] : [])],
+ 'frame-src': [`https://${authDomain}`, ...(authEmulatorOrigin ? [authEmulatorOrigin] : [])],
+ 'object-src': ["'none'"],
+ 'base-uri': ["'self'"],
+ 'form-action': ["'self'"],
+ 'frame-ancestors': ["'none'"],
+ };
+ const policy = Object.entries(directives).map(([name, values]) => `${name} ${values.join(' ')}`);
+ if (!dev) policy.push('upgrade-insecure-requests');
+ return policy.join('; ');
+}
+
+/**
+ * Headers of every response of the admin (wiki Admin-Web › Principes): never indexed, never kept
+ * by a browser or a proxy, then the same security headers as the site.
+ */
+export function responseHeaders(options: HeaderOptions): { key: string; value: string }[] {
+ return [
+ { key: 'X-Robots-Tag', value: 'noindex, nofollow' },
+ { key: 'Cache-Control', value: 'no-store' },
+ { key: 'Content-Security-Policy', value: contentSecurityPolicy(options) },
+ // Two years, without `preload` (same call as the site); ignored by browsers over plain http.
+ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
+ { key: 'X-Frame-Options', value: 'DENY' },
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()' },
+ ];
+}
diff --git a/apps/admin/src/lib/return-path.test.ts b/apps/admin/src/lib/return-path.test.ts
new file mode 100644
index 00000000..a8486967
--- /dev/null
+++ b/apps/admin/src/lib/return-path.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { returnSection, safeReturnPath, signInPathFor } from './return-path';
+
+describe('safeReturnPath', () => {
+ it.each([
+ ['/moderation', '/moderation'],
+ ['/moderation/42', '/moderation/42'],
+ ['/moderation?statut=pending&ville=paris', '/moderation?statut=pending&ville=paris'],
+ ['/', '/'],
+ ])('keeps %s', (value, expected) => {
+ expect(safeReturnPath(value)).toBe(expected);
+ });
+
+ it.each([
+ ['nothing', null],
+ ['an empty value', ''],
+ ['another site', 'https://evil.example/moderation'],
+ ['a protocol-relative address', '//evil.example/moderation'],
+ ['a backslash', '/\\evil.example'],
+ ['a path without leading slash', 'moderation'],
+ ['a script', 'javascript:alert(1)'],
+ ['the sign-in page', '/connexion'],
+ ['an unknown page', '/inconnu'],
+ ])('sends %s to the dashboard', (_, value) => {
+ expect(safeReturnPath(value)).toBe('/');
+ });
+});
+
+describe('signInPathFor', () => {
+ it('remembers the page asked for', () => {
+ expect(signInPathFor('/moderation?statut=pending')).toBe(
+ '/connexion?retour=%2Fmoderation%3Fstatut%3Dpending',
+ );
+ });
+
+ it('adds nothing for the dashboard or an unsafe address', () => {
+ expect(signInPathFor('/')).toBe('/connexion');
+ expect(signInPathFor('//evil.example')).toBe('/connexion');
+ });
+});
+
+describe('returnSection', () => {
+ it('names the section of the page asked for', () => {
+ expect(returnSection('/moderation/42')).toBe('moderation');
+ });
+
+ it('names nothing for the dashboard', () => {
+ expect(returnSection('/')).toBeNull();
+ });
+});
diff --git a/apps/admin/src/lib/return-path.ts b/apps/admin/src/lib/return-path.ts
new file mode 100644
index 00000000..5644f8cb
--- /dev/null
+++ b/apps/admin/src/lib/return-path.ts
@@ -0,0 +1,38 @@
+import { sectionOfPath } from '@/config/navigation';
+import type { AdminSection } from '@widoo/shared';
+
+/** Query parameter of the sign-in page that holds the page asked for. */
+export const returnParam = 'retour';
+
+const signInPath = '/connexion';
+const homePath = '/';
+
+/**
+ * The page to open after signing in, read from an address anyone can write: a path of the admin
+ * only, inside one of its sections. Anything else (another site, `//host`, a backslash, the
+ * sign-in page itself) gives the dashboard: never an open redirect.
+ */
+export function safeReturnPath(value: string | null | undefined): string {
+ if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) {
+ return homePath;
+ }
+ const url = new URL(value, 'http://admin.invalid');
+ if (url.origin !== 'http://admin.invalid' || sectionOfPath(url.pathname) === null) {
+ return homePath;
+ }
+ return `${url.pathname}${url.search}`;
+}
+
+/** Address of the sign-in page that remembers `returnPath`; the dashboard needs no reminder. */
+export function signInPathFor(returnPath: string): string {
+ const safe = safeReturnPath(returnPath);
+ return safe === homePath
+ ? signInPath
+ : `${signInPath}?${new URLSearchParams({ [returnParam]: safe }).toString()}`;
+}
+
+/** The section to name in « Reconnectez-vous pour revenir… »; null for the dashboard. */
+export function returnSection(returnPath: string): AdminSection | null {
+ const section = sectionOfPath(new URL(returnPath, 'http://admin.invalid').pathname);
+ return section === 'dashboard' ? null : section;
+}
diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts
new file mode 100644
index 00000000..e4fa6b5f
--- /dev/null
+++ b/apps/admin/src/messages/fr.ts
@@ -0,0 +1,164 @@
+import type { AdminSection, CountedAdminSection, TeamRole } from '@widoo/shared';
+import type { NavigationGroupId } from '@/config/navigation';
+
+const sections: Record = {
+ dashboard: 'Tableau de bord',
+ moderation: 'Modération',
+ reports: 'Signalements',
+ support: 'Support',
+ catalog: 'Lieux et parcours',
+ aiDrafts: 'Brouillons IA',
+ translations: 'Traductions',
+ users: 'Utilisateurs',
+ affiliation: 'Affiliation',
+ b2bClients: 'Partenaires B2B',
+ settings: 'Réglages',
+ actionLog: 'Journal',
+};
+
+const navigationGroups: Record = {
+ toHandle: 'À traiter',
+ catalog: 'Catalogue',
+ community: 'Communauté',
+ revenue: 'Revenus',
+ administration: 'Administration',
+};
+
+const roles: Record = {
+ editor: 'Éditeur',
+ moderator: 'Modérateur',
+ admin: 'Admin',
+};
+
+/**
+ * Where « Reconnectez-vous pour revenir… » sends back, after « revenir » (A-01, session expirée).
+ */
+const returnTargets: Record = {
+ dashboard: 'au tableau de bord',
+ moderation: 'à la file de modération',
+ reports: 'aux signalements',
+ support: 'au support',
+ catalog: 'aux lieux et parcours',
+ aiDrafts: 'aux brouillons IA',
+ translations: 'aux traductions',
+ users: 'aux utilisateurs',
+ affiliation: "à l'affiliation",
+ b2bClients: 'aux partenaires B2B',
+ settings: 'aux réglages',
+ actionLog: 'au journal',
+};
+
+/** What a counter of the navigation counts, said in full for screen readers and tooltips. */
+const counted: Record string> = {
+ moderation: (count) => `${count} parcours à relire`,
+ reports: (count) => (count > 1 ? `${count} signalements ouverts` : '1 signalement ouvert'),
+ support: (count) => (count > 1 ? `${count} messages à traiter` : '1 message à traiter'),
+ aiDrafts: (count) => (count > 1 ? `${count} brouillons à relire` : '1 brouillon à relire'),
+ translations: (count) => (count > 1 ? `${count} traductions en échec` : '1 traduction en échec'),
+};
+
+/** Texts of the admin. French only (wiki Admin-Web › Principes). */
+export const messages = {
+ app: { name: 'Widoo', product: 'Admin', title: 'Admin Widoo' },
+ sections,
+ roles,
+ shell: {
+ navigation: 'Navigation principale',
+ groups: navigationGroups,
+ collapse: 'Réduire le menu',
+ expand: 'Agrandir le menu',
+ account: 'Compte',
+ signOut: 'Se déconnecter',
+ loading: 'Chargement…',
+ counted,
+ /** After what is counted: « …, dont 2 en retard ». */
+ lateAmong: (lateCount: number) => `, dont ${lateCount} en retard`,
+ },
+ signIn: {
+ title: "Connexion à l'admin Widoo",
+ subtitle: "Réservée aux comptes de l'équipe Widoo.",
+ google: 'Continuer avec Google',
+ or: 'ou',
+ emailLabel: 'Adresse e-mail',
+ emailPlaceholder: 'prenom@widooapp.com',
+ sendLink: 'Recevoir un lien de connexion',
+ help: "Un problème d'accès ? Demandez à un admin de l'équipe.",
+ returnTo: (target: string) => `Reconnectez-vous pour revenir ${target}.`,
+ expired: 'Votre session a expiré.',
+ returnTargets,
+ linkSent: {
+ title: 'Vérifiez votre boîte mail',
+ sentTo: 'Nous avons envoyé un lien de connexion à',
+ openHere: 'Ouvrez-le sur cet ordinateur.',
+ expiry: "Le lien ne sert qu'une fois et expire au bout de quelques heures.",
+ resend: 'Renvoyer le lien',
+ resendIn: (seconds: number) => `Renvoyer le lien dans ${seconds} s`,
+ otherAddress: 'Utiliser une autre adresse',
+ },
+ refusals: {
+ noAccess: {
+ title: "Ce compte n'a pas accès à l'admin.",
+ detail: "L'admin est réservé aux comptes de l'équipe Widoo.",
+ },
+ suspended: {
+ title: 'Ce compte est suspendu.',
+ detail: "Pour en savoir plus, adressez-vous à un admin de l'équipe Widoo.",
+ },
+ },
+ signedInAs: (email: string) => `Vous étiez connecté avec ${email}.`,
+ otherAccount: 'Utiliser un autre compte',
+ code: {
+ label: 'Code à 6 chiffres',
+ placeholder: '000000',
+ },
+ challenge: {
+ title: 'Code de vérification',
+ text: "Saisissez le code à 6 chiffres affiché par votre application d'authentification.",
+ submit: 'Vérifier',
+ },
+ enrollment: {
+ title: 'Activez la double authentification',
+ text: "L'admin Widoo demande un second code à chaque connexion. Il vous faut une application d'authentification sur votre téléphone (Google Authenticator, 1Password, Authy…).",
+ scan: "1. Scannez ce QR code avec l'application.",
+ qrCodeAlt: "QR code à scanner avec l'application d'authentification",
+ manual: 'Ou saisissez cette clé à la main :',
+ keepSecret:
+ 'Ne partagez ni ce QR code ni cette clé, et ne les photographiez pas : ils servent à produire vos codes.',
+ enter: "2. Saisissez le code à 6 chiffres qu'elle affiche.",
+ submit: 'Activer',
+ preparing: 'Préparation du QR code…',
+ },
+ errors: {
+ generic: "La connexion n'a pas abouti. Réessayez.",
+ popupBlocked: 'Votre navigateur a bloqué la fenêtre de Google. Autorisez-la, puis réessayez.',
+ linkInvalid: 'Ce lien de connexion a expiré ou a déjà servi. Demandez-en un nouveau.',
+ codeInvalid: 'Ce code est incorrect ou a expiré. Saisissez le code affiché en ce moment.',
+ recentSignInRequired: 'Votre connexion est trop ancienne pour cette étape. Reconnectez-vous.',
+ linkOtherDevice:
+ "Ouvrez le lien sur l'appareil où vous l'avez demandé, ou demandez-en un nouveau ici.",
+ unavailable: "L'admin ne répond pas pour le moment.",
+ },
+ retry: 'Réessayer',
+ },
+ confirm: {
+ reason: 'Motif',
+ chooseReason: 'Choisir un motif',
+ detail: 'Précision',
+ detailHint: "Gardée dans le journal, visible par l'équipe.",
+ cancel: 'Annuler',
+ close: 'Fermer',
+ },
+ list: {
+ loading: 'Chargement de la liste',
+ pages: 'Pages de la liste',
+ previous: 'Précédent',
+ next: 'Suivant',
+ error: "La liste n'a pas pu être chargée.",
+ retry: 'Réessayer',
+ },
+ notFound: {
+ title: 'Page introuvable',
+ text: "Cette adresse n'existe pas dans l'admin.",
+ back: 'Revenir au tableau de bord',
+ },
+} as const;
diff --git a/apps/admin/tailwind.config.cjs b/apps/admin/tailwind.config.cjs
new file mode 100644
index 00000000..e2ee244e
--- /dev/null
+++ b/apps/admin/tailwind.config.cjs
@@ -0,0 +1,9 @@
+const ui = require('@widoo/ui/tailwind-preset');
+
+// Tokens of tokens.json and theme of shadcn/ui, both through the preset of @widoo/ui. The
+// components of the package are scanned too, so that their classes are kept.
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ['./src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}'],
+ presets: [ui],
+};
diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json
index 564a5990..649d7f0d 100644
--- a/apps/admin/tsconfig.json
+++ b/apps/admin/tsconfig.json
@@ -1,4 +1,15 @@
{
+ // Strict options shared by the monorepo, then what Next.js needs: DOM types, JSX kept for its
+ // compiler, the `next` plugin, and the route types written by `next typegen`.
"extends": "../../tsconfig.base.json",
- "include": ["src"]
+ "compilerOptions": {
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "jsx": "preserve",
+ "allowJs": false,
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["next-env.d.ts", "next.config.ts", "src", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
}
diff --git a/apps/admin/turbo.json b/apps/admin/turbo.json
new file mode 100644
index 00000000..7c8cb326
--- /dev/null
+++ b/apps/admin/turbo.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://turborepo.com/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "outputs": [".next/**", "!.next/cache/**"]
+ }
+ }
+}
diff --git a/apps/admin/vitest.config.ts b/apps/admin/vitest.config.ts
new file mode 100644
index 00000000..e9c57052
--- /dev/null
+++ b/apps/admin/vitest.config.ts
@@ -0,0 +1,11 @@
+import { fileURLToPath } from 'node:url';
+import { defineConfig, mergeConfig } from 'vitest/config';
+import base from '../../vitest.config.ts';
+
+// The `@/` alias of the app.
+export default mergeConfig(
+ base,
+ defineConfig({
+ resolve: { alias: { '@': fileURLToPath(new URL('src', import.meta.url)) } },
+ }),
+);
diff --git a/apps/api/src/admin/actions.test.ts b/apps/api/src/admin/actions.test.ts
new file mode 100644
index 00000000..035679f4
--- /dev/null
+++ b/apps/api/src/admin/actions.test.ts
@@ -0,0 +1,95 @@
+import { eq, inArray } from 'drizzle-orm';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { createDb, createSql, type Db, type Sql } from '../db/client';
+import { adminActions, users } from '../db/schema';
+import { uuidv7 } from '../db/uuid';
+import { testDatabaseUrl } from '../test-database';
+import { recordAdminAction } from './actions';
+
+// Shared database, parallel files: one actor and its own target identifiers, deleted at the end.
+let sql: Sql;
+let db: Db;
+let actorId: string;
+const firebaseUid = `test-admin-actions-${uuidv7()}`;
+const targetIds: string[] = [];
+
+const newTarget = () => {
+ const id = uuidv7();
+ targetIds.push(id);
+ return id;
+};
+const actionsOn = (targetId: string) =>
+ db.select().from(adminActions).where(eq(adminActions.targetId, targetId));
+
+beforeAll(async () => {
+ sql = createSql(testDatabaseUrl());
+ db = createDb(sql);
+ const [actor] = await db.insert(users).values({ firebaseUid, role: 'admin' }).returning();
+ if (!actor) throw new Error('Actor not created');
+ actorId = actor.id;
+});
+afterAll(async () => {
+ await db.delete(adminActions).where(inArray(adminActions.targetId, targetIds));
+ await db.delete(users).where(eq(users.firebaseUid, firebaseUid));
+ await sql.end({ timeout: 5 });
+});
+
+describe('recordAdminAction', () => {
+ it('keeps who, what, on what, before, after and why', async () => {
+ const targetId = newTarget();
+ await recordAdminAction(db, {
+ actorId,
+ action: 'place.close',
+ targetType: 'place',
+ targetId,
+ before: { status: 'verified' },
+ after: { status: 'closed' },
+ reason: 'Fermé définitivement',
+ });
+
+ const [row] = await actionsOn(targetId);
+ expect(row).toMatchObject({
+ actorId,
+ action: 'place.close',
+ targetType: 'place',
+ targetId,
+ before: { status: 'verified' },
+ after: { status: 'closed' },
+ reason: 'Fermé définitivement',
+ });
+ expect(row?.createdAt).toBeInstanceOf(Date);
+ });
+
+ it('is saved with the change it describes, or not at all', async () => {
+ const targetId = newTarget();
+ const failingChange = db.transaction(async (tx) => {
+ await recordAdminAction(tx, {
+ actorId,
+ action: 'route.publish',
+ targetType: 'route',
+ targetId,
+ });
+ throw new Error('the change failed');
+ });
+
+ await expect(failingChange).rejects.toThrow('the change failed');
+ expect(await actionsOn(targetId)).toEqual([]);
+ });
+
+ it('outlives the account of its actor', async () => {
+ const targetId = newTarget();
+ const uid = `test-admin-actions-${uuidv7()}`;
+ const [gone] = await db.insert(users).values({ firebaseUid: uid, role: 'admin' }).returning();
+ if (!gone) throw new Error('Actor not created');
+ await recordAdminAction(db, {
+ actorId: gone.id,
+ action: 'user.suspend',
+ targetType: 'user',
+ targetId,
+ });
+ await db.delete(users).where(eq(users.id, gone.id));
+
+ const [row] = await actionsOn(targetId);
+ expect(row).toMatchObject({ action: 'user.suspend', actorId: null });
+ });
+});
diff --git a/apps/api/src/admin/actions.ts b/apps/api/src/admin/actions.ts
new file mode 100644
index 00000000..e82d89b1
--- /dev/null
+++ b/apps/api/src/admin/actions.ts
@@ -0,0 +1,37 @@
+import type { Db } from '../db/client';
+import { adminActions } from '../db/schema';
+
+/** A write made from the admin, as the journal keeps it (A-12). */
+export type AdminActionEntry = {
+ /** The member of the team who acts. */
+ actorId: string;
+ /** Stable name of the action: `route.publish`, `place.close`, `user.suspend`... */
+ action: string;
+ targetType: string;
+ targetId: string;
+ /** State of the target before and after, limited to what the action changes. */
+ before?: unknown;
+ after?: unknown;
+ /** Why, when the action asks for a reason. */
+ reason?: string;
+};
+
+/**
+ * Writes one row of the journal. Every write route of `/admin/*` calls it in the transaction of
+ * its own change (`db.transaction((tx) => ...)`, then `recordAdminAction(tx, ...)`): the change
+ * and its trace are saved together, or neither is.
+ */
+export async function recordAdminAction(
+ db: Pick,
+ entry: AdminActionEntry,
+): Promise {
+ await db.insert(adminActions).values({
+ actorId: entry.actorId,
+ action: entry.action,
+ targetType: entry.targetType,
+ targetId: entry.targetId,
+ before: entry.before ?? null,
+ after: entry.after ?? null,
+ reason: entry.reason ?? null,
+ });
+}
diff --git a/apps/api/src/admin/counters.ts b/apps/api/src/admin/counters.ts
new file mode 100644
index 00000000..95cc4f41
--- /dev/null
+++ b/apps/api/src/admin/counters.ts
@@ -0,0 +1,43 @@
+import {
+ adminLateAfterHours,
+ canOpenAdminSection,
+ type AdminCounter,
+ type AdminCounters,
+ type UserRole,
+} from '@widoo/shared';
+import { eq, sql } from 'drizzle-orm';
+import type { Db } from '../db/client';
+import { routes } from '../db/schema';
+
+/**
+ * Routes waiting for the team. Late after 72 hours without a change: until the history of a
+ * route exists (`moderation_actions`, with the moderation queue), the last change of a pending
+ * route stands for its submission.
+ */
+async function countPendingRoutes(db: Db, now: Date): Promise {
+ const lateBefore = new Date(now.getTime() - adminLateAfterHours * 60 * 60 * 1000);
+ const [row] = await db
+ .select({
+ count: sql`count(*)::int`,
+ lateCount: sql`(count(*) filter (where ${routes.updatedAt} < ${lateBefore.toISOString()}::timestamptz))::int`,
+ })
+ .from(routes)
+ .where(eq(routes.status, 'pending'));
+ return row ?? { count: 0, lateCount: 0 };
+}
+
+/**
+ * Counters of the navigation of the admin, for the sections `role` opens. Signals, support, AI
+ * drafts and translations join as their tables arrive with their sections.
+ */
+export async function readAdminCounters(
+ db: Db,
+ role: UserRole,
+ now = new Date(),
+): Promise {
+ const counters: AdminCounters = {};
+ if (canOpenAdminSection(role, 'moderation')) {
+ counters.moderation = await countPendingRoutes(db, now);
+ }
+ return counters;
+}
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 866a6a8f..a6cd9fec 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -16,6 +16,7 @@ import { localGeocoder } from './geocode/local';
import { createMapboxGeocoder } from './geocode/mapbox';
import { loggerOptions, requestIdOf, type LogStream } from './logger';
import { parseQueryString } from './query-string';
+import { adminRoutes } from './routes/admin';
import { configRoutes } from './routes/config';
import { editorRoutes } from './routes/editor';
import { geocodeRoutes } from './routes/geocode';
@@ -107,7 +108,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) {
app.addHook('onClose', async () => {
await sql.end({ timeout: 5 });
});
- registerAuth(app, tokenVerifier);
+ registerAuth(app, tokenVerifier, { isSecondFactorRequired: !config.usesAuthEmulator });
if (!config.isProduction) {
await registerDocs(app);
@@ -119,6 +120,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) {
await app.register(meRoutes, { prefix: '/v1' });
await app.register(meExportRoutes, { prefix: '/v1' });
await app.register(plannedRoutesPlugin, { prefix: '/v1' });
+ await app.register(adminRoutes, { prefix: '/v1' });
await app.register(searchRoutesPlugin, { prefix: '/v1' });
await app.register(usersRoutes, { prefix: '/v1' });
await app.register(editorRoutes, { prefix: '/v1' });
diff --git a/apps/api/src/auth/auth.test.ts b/apps/api/src/auth/auth.test.ts
index 61717fc0..742f6a81 100644
--- a/apps/api/src/auth/auth.test.ts
+++ b/apps/api/src/auth/auth.test.ts
@@ -178,6 +178,19 @@ describe('requireAuth', () => {
expect(response.statusCode).toBe(500);
expect(response.body).not.toContain('fictitious outage');
});
+
+ it('answers 403 account_suspended to a suspended account', async () => {
+ const { uid, headers } = signIn();
+ await app.inject({ url: '/v1/test/private', headers });
+ await app.db
+ .update(users)
+ .set({ suspendedAt: new Date(), suspensionReason: 'Test' })
+ .where(eq(users.firebaseUid, uid));
+
+ const response = await app.inject({ url: '/v1/test/private', headers });
+ expect(response.statusCode).toBe(403);
+ expect(ApiError.parse(response.json()).code).toBe('account_suspended');
+ });
});
describe('optionalAuth', () => {
diff --git a/apps/api/src/auth/firebase-verifier.ts b/apps/api/src/auth/firebase-verifier.ts
index 88e075f0..9b8b6bdb 100644
--- a/apps/api/src/auth/firebase-verifier.ts
+++ b/apps/api/src/auth/firebase-verifier.ts
@@ -25,12 +25,17 @@ export function createFirebaseVerifier(projectId: string): TokenVerifier {
async verify(token) {
try {
const decoded = await auth.verifyIdToken(token);
+ // The 12 hours of the admin are counted from it: a token without it is not trusted.
+ if (!Number.isFinite(decoded.auth_time)) {
+ throw new InvalidTokenError('auth/missing-auth-time');
+ }
return {
uid: decoded.uid,
email: decoded.email ?? null,
emailVerified: decoded.email_verified === true,
name: typeof decoded.name === 'string' ? decoded.name : null,
signInProvider: decoded.firebase.sign_in_provider,
+ secondFactor: decoded.firebase.sign_in_second_factor ?? null,
authTime: new Date(decoded.auth_time * 1000),
};
} catch (error) {
diff --git a/apps/api/src/auth/plugin.ts b/apps/api/src/auth/plugin.ts
index babb1743..af62fc18 100644
--- a/apps/api/src/auth/plugin.ts
+++ b/apps/api/src/auth/plugin.ts
@@ -1,8 +1,8 @@
-import { hasRole, type UserRole } from '@widoo/shared';
+import { canOpenAdminSection, hasRole, type AdminSection, type UserRole } from '@widoo/shared';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { httpError } from '../errors';
import { findOrCreateUser, type User } from '../users/repository';
-import { InvalidTokenError, type TokenVerifier } from './verifier';
+import { InvalidTokenError, type TokenVerifier, type VerifiedIdentity } from './verifier';
/** Hook of an authenticated route, to use as `onRequest`: runs before the body is parsed. */
type AuthHook = (request: FastifyRequest) => Promise;
@@ -13,6 +13,12 @@ declare module 'fastify' {
requireAuth: AuthHook;
/** `requireAuth`, then refuses a role that does not hold `role` (403). */
requireRole: (role: Exclude) => AuthHook;
+ /**
+ * Guard of the admin routes (wiki Admin-Web › Rôles et Principes): `requireAuth`, then refuses
+ * a role that does not open `section` (403), a session older than 12 hours (401) and a
+ * session opened without the code of an authenticator app (403 `mfa_required`, D-087).
+ */
+ requireAdminSection: (section: AdminSection) => AuthHook;
/**
* Optional authentication (wiki API › conventions): without an `Authorization` header the
* caller stays anonymous; with one, the same checks as `requireAuth`, 401 included.
@@ -22,20 +28,38 @@ declare module 'fastify' {
interface FastifyRequest {
/** The account of the caller, read from the database; null until an auth hook ran. */
user: User | null;
+ /** What the verified token says of the session; null until an auth hook ran. */
+ identity: VerifiedIdentity | null;
}
}
+// Wiki Admin-Web › Principes: a session of the admin lasts 12 hours at most, then A-01.
+const adminSessionMaxMs = 12 * 60 * 60 * 1000;
+
// A Firebase ID token is about 1 KB; the bound keeps oversized headers away from the verifier.
const BEARER = /^Bearer ([^\s]{1,4096})$/i;
+export type AuthOptions = {
+ /**
+ * False only with the local Auth emulator, which has no TOTP: the admin routes then let a
+ * session without a second factor in. Always true on staging and production.
+ */
+ isSecondFactorRequired: boolean;
+};
+
/**
* Authentication on demand: public routes stay public, authenticated routes declare
* `onRequest: app.requireAuth` or `app.requireRole(...)`, a public route whose answer depends on
* the caller `app.optionalAuth`. The role always comes from the database, read again on every
* request, never from the token.
*/
-export function registerAuth(app: FastifyInstance, verifier: TokenVerifier): void {
+export function registerAuth(
+ app: FastifyInstance,
+ verifier: TokenVerifier,
+ options: AuthOptions,
+): void {
app.decorateRequest('user', null);
+ app.decorateRequest('identity', null);
async function authenticate(request: FastifyRequest): Promise {
if (request.user) {
@@ -62,7 +86,12 @@ export function registerAuth(app: FastifyInstance, verifier: TokenVerifier): voi
if (signedIn.kind === 'signed_out') {
throw httpError(401, 'Account deleted');
}
+ // Refused everywhere, the app included: the account can no longer sign in (A-06).
+ if (signedIn.user.suspendedAt) {
+ throw httpError(403, 'Account suspended', 'account_suspended');
+ }
request.user = signedIn.user;
+ request.identity = identity;
return signedIn.user;
}
@@ -84,4 +113,19 @@ export function registerAuth(app: FastifyInstance, verifier: TokenVerifier): voi
}
},
);
+ app.decorate('requireAdminSection', (section: AdminSection) => async (request) => {
+ const user = await authenticate(request);
+ // The role first: an account outside the team learns nothing about the admin.
+ if (!canOpenAdminSection(user.role, section)) {
+ throw httpError(403, 'Insufficient role');
+ }
+ const identity = request.identity;
+ if (!identity || Date.now() - identity.authTime.getTime() > adminSessionMaxMs) {
+ throw httpError(401, 'Admin session expired');
+ }
+ // The authenticator app only (D-087): a text message is not a second factor of the admin.
+ if (options.isSecondFactorRequired && identity.secondFactor !== 'totp') {
+ throw httpError(403, 'Second factor required', 'mfa_required');
+ }
+ });
}
diff --git a/apps/api/src/auth/test-verifier.ts b/apps/api/src/auth/test-verifier.ts
index 253695fa..e129f9df 100644
--- a/apps/api/src/auth/test-verifier.ts
+++ b/apps/api/src/auth/test-verifier.ts
@@ -10,8 +10,8 @@ export class TestTokenVerifier implements TokenVerifier {
readonly #identities = new Map();
/**
- * A token for a new identity (unique `uid`, Google, no e-mail unless given), signed in a minute
- * ago unless `authTime` is given.
+ * A token for a new identity (unique `uid`, Google, no second factor, no e-mail unless given),
+ * signed in a minute ago unless `authTime` is given.
*/
issue(identity: Partial = {}): { token: string; uid: string } {
const token = `test-token-${randomUUID()}`;
@@ -21,6 +21,7 @@ export class TestTokenVerifier implements TokenVerifier {
emailVerified: false,
name: null,
signInProvider: 'google.com',
+ secondFactor: null,
authTime: new Date(Date.now() - 60_000),
...identity,
};
diff --git a/apps/api/src/auth/verifier.ts b/apps/api/src/auth/verifier.ts
index 6ca561c3..17df4b28 100644
--- a/apps/api/src/auth/verifier.ts
+++ b/apps/api/src/auth/verifier.ts
@@ -9,6 +9,11 @@ export type VerifiedIdentity = {
name: string | null;
/** `firebase.sign_in_provider`: `apple.com`, `google.com`, `password`, `anonymous`... */
signInProvider: string;
+ /**
+ * `firebase.sign_in_second_factor` (`totp`, `phone`): the second factor this session was opened
+ * with, null without one. The admin requires one (D-087).
+ */
+ secondFactor: string | null;
/**
* `auth_time`: when the user signed in, kept by every refresh of the token. A session opened
* before the deletion of the account is refused; a later sign-in gets a new account (D-027).
diff --git a/apps/api/src/config.test.ts b/apps/api/src/config.test.ts
index 3e29e6fc..425b65da 100644
--- a/apps/api/src/config.test.ts
+++ b/apps/api/src/config.test.ts
@@ -18,6 +18,7 @@ describe('loadConfig', () => {
trustedProxies: [],
rateLimitMax: 1000,
firebaseProjectId: undefined,
+ usesAuthEmulator: false,
sentryDsn: undefined,
sentryEnvironment: 'development',
internalToken: undefined,
@@ -153,4 +154,15 @@ describe('loadConfig', () => {
expect(load({ NODE_ENV: 'production' })).not.toThrow();
expect(load({ NODE_ENV: 'development', ...emulator, ...demo })).not.toThrow();
});
+
+ it('refuses the Auth emulator with a real project, in every environment', () => {
+ const load = () =>
+ loadConfig({
+ DATABASE_URL: databaseUrl,
+ NODE_ENV: 'development',
+ FIREBASE_PROJECT_ID: 'widoo-staging',
+ FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099',
+ });
+ expect(load).toThrow(/demo- project/);
+ });
});
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index 3fd12bd5..f06d7214 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -123,6 +123,18 @@ const Env = z.object({
/** Guards against local settings that would weaken production: token checks, report transport. */
const ProductionEnv = Env.superRefine((env, context) => {
+ // The emulator accepts unsigned tokens: with a demo project only, which exists nowhere else.
+ // The admin applies the same rule on its side.
+ if (
+ env.FIREBASE_AUTH_EMULATOR_HOST !== undefined &&
+ !env.FIREBASE_PROJECT_ID?.startsWith('demo-')
+ ) {
+ context.addIssue({
+ code: 'custom',
+ path: ['FIREBASE_AUTH_EMULATOR_HOST'],
+ message: 'The Auth emulator needs a demo- project',
+ });
+ }
if (env.NODE_ENV !== 'production') {
return;
}
@@ -160,6 +172,11 @@ export const Config = ProductionEnv.transform((env) => ({
trustedProxies: env.TRUST_PROXY,
rateLimitMax: env.RATE_LIMIT_MAX,
firebaseProjectId: env.FIREBASE_PROJECT_ID,
+ /**
+ * The local Auth emulator signs the tokens (never in production, refused above). It has no
+ * TOTP second factor, so the admin routes cannot ask for one there.
+ */
+ usesAuthEmulator: Boolean(env.FIREBASE_AUTH_EMULATOR_HOST),
sentryDsn: env.SENTRY_DSN,
sentryEnvironment: env.SENTRY_ENVIRONMENT ?? env.NODE_ENV,
internalToken: env.INTERNAL_TOKEN,
diff --git a/apps/api/src/db/generated/migrations/0015_add_admin_actions.sql b/apps/api/src/db/generated/migrations/0015_add_admin_actions.sql
new file mode 100644
index 00000000..8fcaaed1
--- /dev/null
+++ b/apps/api/src/db/generated/migrations/0015_add_admin_actions.sql
@@ -0,0 +1,18 @@
+CREATE TABLE "admin_actions" (
+ "id" uuid PRIMARY KEY NOT NULL,
+ "actor_id" uuid,
+ "action" text NOT NULL,
+ "target_type" text NOT NULL,
+ "target_id" text NOT NULL,
+ "before" jsonb,
+ "after" jsonb,
+ "reason" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "suspended_at" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "suspension_reason" text;--> statement-breakpoint
+ALTER TABLE "admin_actions" ADD CONSTRAINT "admin_actions_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "admin_actions_created_at_idx" ON "admin_actions" USING btree ("created_at");--> statement-breakpoint
+CREATE INDEX "admin_actions_target_idx" ON "admin_actions" USING btree ("target_type","target_id");--> statement-breakpoint
+CREATE INDEX "admin_actions_actor_idx" ON "admin_actions" USING btree ("actor_id");
\ No newline at end of file
diff --git a/apps/api/src/db/generated/migrations/meta/0015_snapshot.json b/apps/api/src/db/generated/migrations/meta/0015_snapshot.json
new file mode 100644
index 00000000..2841fd2f
--- /dev/null
+++ b/apps/api/src/db/generated/migrations/meta/0015_snapshot.json
@@ -0,0 +1,3132 @@
+{
+ "id": "fccdd842-f347-49a9-b60b-684251765015",
+ "prevId": "80de5e89-48fe-403a-b81b-317c3421a3a4",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.admin_actions": {
+ "name": "admin_actions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "before": {
+ "name": "before",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "after": {
+ "name": "after",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "admin_actions_created_at_idx": {
+ "name": "admin_actions_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "admin_actions_target_idx": {
+ "name": "admin_actions_target_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "admin_actions_actor_idx": {
+ "name": "admin_actions_actor_idx",
+ "columns": [
+ {
+ "expression": "actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "admin_actions_actor_id_users_id_fk": {
+ "name": "admin_actions_actor_id_users_id_fk",
+ "tableFrom": "admin_actions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anecdote_questions": {
+ "name": "anecdote_questions",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "anecdote_questions_key_check": {
+ "name": "anecdote_questions_key_check",
+ "value": "\"anecdote_questions\".\"key\" ~ '^[a-z0-9]+(_[a-z0-9]+)*$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.cities": {
+ "name": "cities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "center": {
+ "name": "center",
+ "type": "geography(Point,4326)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bounds": {
+ "name": "bounds",
+ "type": "geography(Polygon,4326)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "cities_slug_unique": {
+ "name": "cities_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.devices": {
+ "name": "devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "push_token": {
+ "name": "push_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "devices_user_updated_idx": {
+ "name": "devices_user_updated_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "devices_user_id_users_id_fk": {
+ "name": "devices_user_id_users_id_fk",
+ "tableFrom": "devices",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "devices_push_token_unique": {
+ "name": "devices_push_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "push_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "devices_locale_check": {
+ "name": "devices_locale_check",
+ "value": "(\"devices\".\"locale\" in ('fr', 'en'))"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.direction_cache": {
+ "name": "direction_cache",
+ "schema": "",
+ "columns": {
+ "profile": {
+ "name": "profile",
+ "type": "directions_profile",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_lat_e5": {
+ "name": "from_lat_e5",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_lng_e5": {
+ "name": "from_lng_e5",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "to_lat_e5": {
+ "name": "to_lat_e5",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "to_lng_e5": {
+ "name": "to_lng_e5",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "distance_m": {
+ "name": "distance_m",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "geometry": {
+ "name": "geometry",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "direction_cache_from_idx": {
+ "name": "direction_cache_from_idx",
+ "columns": [
+ {
+ "expression": "from_lat_e5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "from_lng_e5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "direction_cache_to_idx": {
+ "name": "direction_cache_to_idx",
+ "columns": [
+ {
+ "expression": "to_lat_e5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "to_lng_e5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "direction_cache_pkey": {
+ "name": "direction_cache_pkey",
+ "columns": [
+ "profile",
+ "from_lat_e5",
+ "from_lng_e5",
+ "to_lat_e5",
+ "to_lng_e5"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "direction_cache_measures_check": {
+ "name": "direction_cache_measures_check",
+ "value": "\"direction_cache\".\"distance_m\" >= 0 and \"direction_cache\".\"duration_s\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_type": {
+ "name": "owner_type",
+ "type": "media_owner_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_path": {
+ "name": "storage_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "media_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'upload'"
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "license": {
+ "name": "license",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "media_owner_idx": {
+ "name": "media_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "media_step_position_idx": {
+ "name": "media_step_position_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"media\".\"owner_type\" not in ('route', 'place')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_uploaded_by_users_id_fk": {
+ "name": "media_uploaded_by_users_id_fk",
+ "tableFrom": "media",
+ "tableTo": "users",
+ "columnsFrom": [
+ "uploaded_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "media_step_position_check": {
+ "name": "media_step_position_check",
+ "value": "\"media\".\"owner_type\" in ('route', 'place')\n or \"media\".\"position\" between 0 and 4"
+ },
+ "media_credit_check": {
+ "name": "media_credit_check",
+ "value": "(\"media\".\"source\" = 'upload'\n and \"media\".\"author\" is null and \"media\".\"license\" is null and \"media\".\"source_url\" is null)\n or (\"media\".\"source\" = 'commons'\n and coalesce(\"media\".\"author\", '') <> '' and coalesce(\"media\".\"license\", '') <> ''\n and coalesce(\"media\".\"source_url\", '') like 'https://%')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.notifications": {
+ "name": "notifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "notification_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_push_at": {
+ "name": "sent_push_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_email_at": {
+ "name": "sent_email_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notifications_user_created_idx": {
+ "name": "notifications_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.place_hours": {
+ "name": "place_hours",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "place_id": {
+ "name": "place_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weekday": {
+ "name": "weekday",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "opens": {
+ "name": "opens",
+ "type": "time",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "closes": {
+ "name": "closes",
+ "type": "time",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "valid_from": {
+ "name": "valid_from",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valid_to": {
+ "name": "valid_to",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "place_hours_place_idx": {
+ "name": "place_hours_place_idx",
+ "columns": [
+ {
+ "expression": "place_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "place_hours_place_id_places_id_fk": {
+ "name": "place_hours_place_id_places_id_fk",
+ "tableFrom": "place_hours",
+ "tableTo": "places",
+ "columnsFrom": [
+ "place_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "place_hours_weekday_check": {
+ "name": "place_hours_weekday_check",
+ "value": "\"place_hours\".\"weekday\" between 0 and 6"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.places": {
+ "name": "places",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "city_id": {
+ "name": "city_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "place_category",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "geography(Point,4326)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address_components": {
+ "name": "address_components",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_indoor": {
+ "name": "is_indoor",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "price_range": {
+ "name": "price_range",
+ "type": "price_range",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website_url": {
+ "name": "website_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_refs": {
+ "name": "external_refs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "verification_status": {
+ "name": "verification_status",
+ "type": "verification_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "always_open": {
+ "name": "always_open",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ends_on": {
+ "name": "ends_on",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmations_since_verified": {
+ "name": "confirmations_since_verified",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "places_location_idx": {
+ "name": "places_location_idx",
+ "columns": [
+ {
+ "expression": "location",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gist",
+ "with": {}
+ },
+ "places_city_category_idx": {
+ "name": "places_city_category_idx",
+ "columns": [
+ {
+ "expression": "city_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "places_name_trgm_idx": {
+ "name": "places_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "places_city_id_cities_id_fk": {
+ "name": "places_city_id_cities_id_fk",
+ "tableFrom": "places",
+ "tableTo": "cities",
+ "columnsFrom": [
+ "city_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "places_created_by_users_id_fk": {
+ "name": "places_created_by_users_id_fk",
+ "tableFrom": "places",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.planned_routes": {
+ "name": "planned_routes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "route_id": {
+ "name": "route_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "route_revision_snapshot": {
+ "name": "route_revision_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "starts_at": {
+ "name": "starts_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "planned_route_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'planned'"
+ },
+ "timeline": {
+ "name": "timeline",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checklist": {
+ "name": "checklist",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closed_by": {
+ "name": "closed_by",
+ "type": "planned_route_closer",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "planned_routes_user_starts_idx": {
+ "name": "planned_routes_user_starts_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "starts_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "planned_routes_route_status_idx": {
+ "name": "planned_routes_route_status_idx",
+ "columns": [
+ {
+ "expression": "route_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "planned_routes_one_in_progress_idx": {
+ "name": "planned_routes_one_in_progress_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"planned_routes\".\"status\" = 'in_progress'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "planned_routes_user_id_users_id_fk": {
+ "name": "planned_routes_user_id_users_id_fk",
+ "tableFrom": "planned_routes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "planned_routes_route_id_routes_id_fk": {
+ "name": "planned_routes_route_id_routes_id_fk",
+ "tableFrom": "planned_routes",
+ "tableTo": "routes",
+ "columnsFrom": [
+ "route_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "planned_routes_closed_by_check": {
+ "name": "planned_routes_closed_by_check",
+ "value": "\"planned_routes\".\"closed_by\" is null or \"planned_routes\".\"status\" in ('completed', 'stopped')"
+ },
+ "planned_routes_completed_at_check": {
+ "name": "planned_routes_completed_at_check",
+ "value": "\"planned_routes\".\"completed_at\" is null or \"planned_routes\".\"status\" = 'completed'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.planned_steps": {
+ "name": "planned_steps",
+ "schema": "",
+ "columns": {
+ "planned_route_id": {
+ "name": "planned_route_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_id": {
+ "name": "step_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "arrived_at": {
+ "name": "arrived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "done_at": {
+ "name": "done_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "skipped_at": {
+ "name": "skipped_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "planned_steps_planned_route_id_planned_routes_id_fk": {
+ "name": "planned_steps_planned_route_id_planned_routes_id_fk",
+ "tableFrom": "planned_steps",
+ "tableTo": "planned_routes",
+ "columnsFrom": [
+ "planned_route_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "planned_steps_pkey": {
+ "name": "planned_steps_pkey",
+ "columns": [
+ "planned_route_id",
+ "step_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "planned_steps_route_position_unique": {
+ "name": "planned_steps_route_position_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "planned_route_id",
+ "position"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "planned_steps_done_or_skipped_check": {
+ "name": "planned_steps_done_or_skipped_check",
+ "value": "\"planned_steps\".\"done_at\" is null or \"planned_steps\".\"skipped_at\" is null"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.route_reports": {
+ "name": "route_reports",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "route_id": {
+ "name": "route_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "route_report_reason",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "step_id": {
+ "name": "step_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment": {
+ "name": "comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "report_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "route_reports_route_user_idx": {
+ "name": "route_reports_route_user_idx",
+ "columns": [
+ {
+ "expression": "route_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "route_reports_open_idx": {
+ "name": "route_reports_open_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"route_reports\".\"status\" = 'open'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "route_reports_one_open_low_rating_idx": {
+ "name": "route_reports_one_open_low_rating_idx",
+ "columns": [
+ {
+ "expression": "route_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"route_reports\".\"reason\" = 'low_rating' and \"route_reports\".\"status\" = 'open'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "route_reports_route_id_routes_id_fk": {
+ "name": "route_reports_route_id_routes_id_fk",
+ "tableFrom": "route_reports",
+ "tableTo": "routes",
+ "columnsFrom": [
+ "route_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "route_reports_user_id_users_id_fk": {
+ "name": "route_reports_user_id_users_id_fk",
+ "tableFrom": "route_reports",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "route_reports_reviewed_by_users_id_fk": {
+ "name": "route_reports_reviewed_by_users_id_fk",
+ "tableFrom": "route_reports",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "route_reports_reporter_check": {
+ "name": "route_reports_reporter_check",
+ "value": "(\"route_reports\".\"user_id\" is null) = (\"route_reports\".\"reason\" = 'low_rating')"
+ },
+ "route_reports_step_check": {
+ "name": "route_reports_step_check",
+ "value": "(\"route_reports\".\"step_id\" is not null) = (\"route_reports\".\"reason\" in ('place_changed'))"
+ },
+ "route_reports_comment_check": {
+ "name": "route_reports_comment_check",
+ "value": "\"route_reports\".\"comment\" is null or ((\"route_reports\".\"reason\" in ('place_changed', 'other')) and btrim(\"route_reports\".\"comment\") <> '' and char_length(\"route_reports\".\"comment\") <= 80)"
+ },
+ "route_reports_comment_required_check": {
+ "name": "route_reports_comment_required_check",
+ "value": "\"route_reports\".\"comment\" is not null or not (\"route_reports\".\"reason\" in ('other'))"
+ },
+ "route_reports_review_check": {
+ "name": "route_reports_review_check",
+ "value": "(\"route_reports\".\"status\" = 'open') = (\"route_reports\".\"reviewed_at\" is null)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.route_revisions": {
+ "name": "route_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "route_id": {
+ "name": "route_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "route_revision_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "submitted_at": {
+ "name": "submitted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "route_revisions_one_open_idx": {
+ "name": "route_revisions_one_open_idx",
+ "columns": [
+ {
+ "expression": "route_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"route_revisions\".\"status\" in ('draft', 'pending', 'changes_requested')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "route_revisions_route_idx": {
+ "name": "route_revisions_route_idx",
+ "columns": [
+ {
+ "expression": "route_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "route_revisions_route_id_routes_id_fk": {
+ "name": "route_revisions_route_id_routes_id_fk",
+ "tableFrom": "route_revisions",
+ "tableTo": "routes",
+ "columnsFrom": [
+ "route_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "route_revisions_author_id_users_id_fk": {
+ "name": "route_revisions_author_id_users_id_fk",
+ "tableFrom": "route_revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.route_unlocks": {
+ "name": "route_unlocks",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "route_id": {
+ "name": "route_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "store_transaction_id": {
+ "name": "store_transaction_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "route_unlocks_route_idx": {
+ "name": "route_unlocks_route_idx",
+ "columns": [
+ {
+ "expression": "route_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "route_unlocks_user_id_users_id_fk": {
+ "name": "route_unlocks_user_id_users_id_fk",
+ "tableFrom": "route_unlocks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "route_unlocks_route_id_routes_id_fk": {
+ "name": "route_unlocks_route_id_routes_id_fk",
+ "tableFrom": "route_unlocks",
+ "tableTo": "routes",
+ "columnsFrom": [
+ "route_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "route_unlocks_pkey": {
+ "name": "route_unlocks_pkey",
+ "columns": [
+ "user_id",
+ "route_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "route_unlocks_store_transaction_id_unique": {
+ "name": "route_unlocks_store_transaction_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "store_transaction_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routes": {
+ "name": "routes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "city_id": {
+ "name": "city_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_official": {
+ "name": "is_official",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "status": {
+ "name": "status",
+ "type": "route_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "access": {
+ "name": "access",
+ "type": "plan",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'free'"
+ },
+ "sponsored": {
+ "name": "sponsored",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "moods": {
+ "name": "moods",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "audiences": {
+ "name": "audiences",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "transport": {
+ "name": "transport",
+ "type": "transport",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'walk'"
+ },
+ "suggested_start": {
+ "name": "suggested_start",
+ "type": "time",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tips": {
+ "name": "tips",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "tips_hidden": {
+ "name": "tips_hidden",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "start_location": {
+ "name": "start_location",
+ "type": "geography(Point,4326)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bounds": {
+ "name": "bounds",
+ "type": "geography(Polygon,4326)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "computed": {
+ "name": "computed",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_bucket": {
+ "name": "duration_bucket",
+ "type": "duration_bucket",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "budget_bucket": {
+ "name": "budget_bucket",
+ "type": "budget_bucket",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stats": {
+ "name": "stats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "recommendation": {
+ "name": "recommendation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "routes_bounds_idx": {
+ "name": "routes_bounds_idx",
+ "columns": [
+ {
+ "expression": "bounds",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gist",
+ "with": {}
+ },
+ "routes_start_location_idx": {
+ "name": "routes_start_location_idx",
+ "columns": [
+ {
+ "expression": "start_location",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gist",
+ "with": {}
+ },
+ "routes_city_status_idx": {
+ "name": "routes_city_status_idx",
+ "columns": [
+ {
+ "expression": "city_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routes_moods_idx": {
+ "name": "routes_moods_idx",
+ "columns": [
+ {
+ "expression": "moods",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "routes_audiences_idx": {
+ "name": "routes_audiences_idx",
+ "columns": [
+ {
+ "expression": "audiences",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "routes_conditions_idx": {
+ "name": "routes_conditions_idx",
+ "columns": [
+ {
+ "expression": "conditions",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "routes_duration_bucket_idx": {
+ "name": "routes_duration_bucket_idx",
+ "columns": [
+ {
+ "expression": "duration_bucket",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routes_budget_bucket_idx": {
+ "name": "routes_budget_bucket_idx",
+ "columns": [
+ {
+ "expression": "budget_bucket",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routes_city_id_cities_id_fk": {
+ "name": "routes_city_id_cities_id_fk",
+ "tableFrom": "routes",
+ "tableTo": "cities",
+ "columnsFrom": [
+ "city_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "routes_author_id_users_id_fk": {
+ "name": "routes_author_id_users_id_fk",
+ "tableFrom": "routes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "routes_share_token_unique": {
+ "name": "routes_share_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "share_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "routes_moods_check": {
+ "name": "routes_moods_check",
+ "value": "\"routes\".\"moods\" <@ ARRAY['culture', 'nature', 'shopping', 'food', 'romantic', 'unusual', 'instagrammable', 'relax', 'sport']::text[]"
+ },
+ "routes_audiences_check": {
+ "name": "routes_audiences_check",
+ "value": "\"routes\".\"audiences\" <@ ARRAY['solo', 'couple', 'family', 'friends', 'dog_friendly', 'kids_friendly']::text[]"
+ },
+ "routes_conditions_check": {
+ "name": "routes_conditions_check",
+ "value": "\"routes\".\"conditions\" <@ ARRAY['no_booking', 'wheelchair', 'indoor', 'outdoor', 'sunny', 'rainy']::text[]"
+ },
+ "routes_title_length_check": {
+ "name": "routes_title_length_check",
+ "value": "char_length(\"routes\".\"title\") <= 60"
+ },
+ "routes_tips_check": {
+ "name": "routes_tips_check",
+ "value": "(cardinality(\"routes\".\"tips\") = 0 or (array_ndims(\"routes\".\"tips\") = 1\n and array_position(\"routes\".\"tips\", null) is null\n and array_position(\"routes\".\"tips\", '') is null)) and cardinality(\"routes\".\"tips\") <= 3"
+ },
+ "routes_tips_hidden_check": {
+ "name": "routes_tips_hidden_check",
+ "value": "(cardinality(\"routes\".\"tips_hidden\") = 0 or (array_ndims(\"routes\".\"tips_hidden\") = 1\n and array_position(\"routes\".\"tips_hidden\", null) is null\n and array_position(\"routes\".\"tips_hidden\", '') is null))"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.steps": {
+ "name": "steps",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "route_id": {
+ "name": "route_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "place_id": {
+ "name": "place_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_min": {
+ "name": "duration_min",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cost_per_person": {
+ "name": "cost_per_person",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "booking_required": {
+ "name": "booking_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "booking_url": {
+ "name": "booking_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "booking_provider": {
+ "name": "booking_provider",
+ "type": "booking_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "booking_note": {
+ "name": "booking_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "booking_is_affiliate": {
+ "name": "booking_is_affiliate",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ends_on": {
+ "name": "ends_on",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transition_note": {
+ "name": "transition_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transition": {
+ "name": "transition",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "highlights": {
+ "name": "highlights",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "audio_path": {
+ "name": "audio_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "audio_duration_s": {
+ "name": "audio_duration_s",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "steps_place_idx": {
+ "name": "steps_place_idx",
+ "columns": [
+ {
+ "expression": "place_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "steps_route_id_routes_id_fk": {
+ "name": "steps_route_id_routes_id_fk",
+ "tableFrom": "steps",
+ "tableTo": "routes",
+ "columnsFrom": [
+ "route_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "steps_place_id_places_id_fk": {
+ "name": "steps_place_id_places_id_fk",
+ "tableFrom": "steps",
+ "tableTo": "places",
+ "columnsFrom": [
+ "place_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "steps_route_position_unique": {
+ "name": "steps_route_position_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "route_id",
+ "position"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "steps_highlights_check": {
+ "name": "steps_highlights_check",
+ "value": "\"steps\".\"highlights\" is null or (array_ndims(\"steps\".\"highlights\") = 1\n and cardinality(\"steps\".\"highlights\") between 1 and 3\n and array_position(\"steps\".\"highlights\", null) is null\n and array_position(\"steps\".\"highlights\", '') is null)"
+ },
+ "steps_audio_check": {
+ "name": "steps_audio_check",
+ "value": "(\"steps\".\"audio_path\" is null and \"steps\".\"audio_duration_s\" is null)\n or (\"steps\".\"audio_path\" is not null and \"steps\".\"audio_duration_s\" is not null and \"steps\".\"audio_duration_s\" > 0)"
+ },
+ "steps_description_length_check": {
+ "name": "steps_description_length_check",
+ "value": "char_length(\"steps\".\"description\") <= 600"
+ },
+ "steps_booking_note_length_check": {
+ "name": "steps_booking_note_length_check",
+ "value": "char_length(\"steps\".\"booking_note\") <= 80"
+ },
+ "steps_booking_affiliate_check": {
+ "name": "steps_booking_affiliate_check",
+ "value": "not \"steps\".\"booking_is_affiliate\" or \"steps\".\"booking_url\" is not null"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.support_messages": {
+ "name": "support_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reply_email": {
+ "name": "reply_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "topic": {
+ "name": "topic",
+ "type": "support_topic",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app_version": {
+ "name": "app_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device": {
+ "name": "device",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "handled_at": {
+ "name": "handled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "support_messages_user_idx": {
+ "name": "support_messages_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "support_messages_unhandled_idx": {
+ "name": "support_messages_unhandled_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"support_messages\".\"handled_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "support_messages_user_id_users_id_fk": {
+ "name": "support_messages_user_id_users_id_fk",
+ "tableFrom": "support_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "support_messages_message_length_check": {
+ "name": "support_messages_message_length_check",
+ "value": "char_length(\"support_messages\".\"message\") between 1 and 1000"
+ },
+ "support_messages_locale_check": {
+ "name": "support_messages_locale_check",
+ "value": "(\"support_messages\".\"locale\" in ('fr', 'en'))"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_anecdotes": {
+ "name": "user_anecdotes",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "question_key": {
+ "name": "question_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "answer": {
+ "name": "answer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_anecdotes_user_id_users_id_fk": {
+ "name": "user_anecdotes_user_id_users_id_fk",
+ "tableFrom": "user_anecdotes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_anecdotes_question_key_anecdote_questions_key_fk": {
+ "name": "user_anecdotes_question_key_anecdote_questions_key_fk",
+ "tableFrom": "user_anecdotes",
+ "tableTo": "anecdote_questions",
+ "columnsFrom": [
+ "question_key"
+ ],
+ "columnsTo": [
+ "key"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_anecdotes_pkey": {
+ "name": "user_anecdotes_pkey",
+ "columns": [
+ "user_id",
+ "position"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "user_anecdotes_user_question_unique": {
+ "name": "user_anecdotes_user_question_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "question_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "user_anecdotes_position_check": {
+ "name": "user_anecdotes_position_check",
+ "value": "\"user_anecdotes\".\"position\" between 1 and 3"
+ },
+ "user_anecdotes_answer_length_check": {
+ "name": "user_anecdotes_answer_length_check",
+ "value": "char_length(\"user_anecdotes\".\"answer\") between 1 and 150"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_featured_routes": {
+ "name": "user_featured_routes",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "route_id": {
+ "name": "route_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "user_featured_routes_route_idx": {
+ "name": "user_featured_routes_route_idx",
+ "columns": [
+ {
+ "expression": "route_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_featured_routes_user_id_users_id_fk": {
+ "name": "user_featured_routes_user_id_users_id_fk",
+ "tableFrom": "user_featured_routes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_featured_routes_route_id_routes_id_fk": {
+ "name": "user_featured_routes_route_id_routes_id_fk",
+ "tableFrom": "user_featured_routes",
+ "tableTo": "routes",
+ "columnsFrom": [
+ "route_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_featured_routes_pkey": {
+ "name": "user_featured_routes_pkey",
+ "columns": [
+ "user_id",
+ "position"
+ ]
+ }
+ },
+ "uniqueConstraints": {
+ "user_featured_routes_user_route_unique": {
+ "name": "user_featured_routes_user_route_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "route_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "user_featured_routes_position_check": {
+ "name": "user_featured_routes_position_check",
+ "value": "\"user_featured_routes\".\"position\" between 1 and 3"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "firebase_uid": {
+ "name": "firebase_uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "user_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "plan": {
+ "name": "plan",
+ "type": "plan",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'free'"
+ },
+ "plan_expires_at": {
+ "name": "plan_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revenuecat_app_user_id": {
+ "name": "revenuecat_app_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "neighborhood": {
+ "name": "neighborhood",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_certified": {
+ "name": "is_certified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "certified_override": {
+ "name": "certified_override",
+ "type": "certified_override",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "certified_at": {
+ "name": "certified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_score": {
+ "name": "trust_score",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "notification_prefs": {
+ "name": "notification_prefs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "suspended_at": {
+ "name": "suspended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "suspension_reason": {
+ "name": "suspension_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "users_firebase_uid_active_idx": {
+ "name": "users_firebase_uid_active_idx",
+ "columns": [
+ {
+ "expression": "firebase_uid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"users\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_firebase_uid_deleted_idx": {
+ "name": "users_firebase_uid_deleted_idx",
+ "columns": [
+ {
+ "expression": "firebase_uid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "users_bio_length_check": {
+ "name": "users_bio_length_check",
+ "value": "char_length(\"users\".\"bio\") <= 150"
+ },
+ "users_locale_check": {
+ "name": "users_locale_check",
+ "value": "\"users\".\"locale\" is null or (\"users\".\"locale\" in ('fr', 'en'))"
+ }
+ },
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.booking_provider": {
+ "name": "booking_provider",
+ "schema": "public",
+ "values": [
+ "restaurant",
+ "ticketing",
+ "activity",
+ "other"
+ ]
+ },
+ "public.budget_bucket": {
+ "name": "budget_bucket",
+ "schema": "public",
+ "values": [
+ "free",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.certified_override": {
+ "name": "certified_override",
+ "schema": "public",
+ "values": [
+ "none",
+ "granted",
+ "revoked"
+ ]
+ },
+ "public.directions_profile": {
+ "name": "directions_profile",
+ "schema": "public",
+ "values": [
+ "walking",
+ "cycling",
+ "driving"
+ ]
+ },
+ "public.duration_bucket": {
+ "name": "duration_bucket",
+ "schema": "public",
+ "values": [
+ "1_2h",
+ "half_day",
+ "full_day",
+ "weekend"
+ ]
+ },
+ "public.media_owner_type": {
+ "name": "media_owner_type",
+ "schema": "public",
+ "values": [
+ "route",
+ "place",
+ "step"
+ ]
+ },
+ "public.media_source": {
+ "name": "media_source",
+ "schema": "public",
+ "values": [
+ "upload",
+ "commons"
+ ]
+ },
+ "public.notification_kind": {
+ "name": "notification_kind",
+ "schema": "public",
+ "values": [
+ "go_closed",
+ "report_resolved"
+ ]
+ },
+ "public.place_category": {
+ "name": "place_category",
+ "schema": "public",
+ "values": [
+ "restaurant",
+ "cafe",
+ "bar",
+ "bakery",
+ "museum",
+ "gallery",
+ "monument",
+ "park",
+ "viewpoint",
+ "shop",
+ "activity",
+ "event_venue",
+ "walk",
+ "other"
+ ]
+ },
+ "public.plan": {
+ "name": "plan",
+ "schema": "public",
+ "values": [
+ "free",
+ "premium"
+ ]
+ },
+ "public.planned_route_closer": {
+ "name": "planned_route_closer",
+ "schema": "public",
+ "values": [
+ "user",
+ "auto"
+ ]
+ },
+ "public.planned_route_status": {
+ "name": "planned_route_status",
+ "schema": "public",
+ "values": [
+ "planned",
+ "in_progress",
+ "completed",
+ "stopped",
+ "cancelled",
+ "missed"
+ ]
+ },
+ "public.price_range": {
+ "name": "price_range",
+ "schema": "public",
+ "values": [
+ "free",
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.report_status": {
+ "name": "report_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "accepted",
+ "rejected"
+ ]
+ },
+ "public.route_report_reason": {
+ "name": "route_report_reason",
+ "schema": "public",
+ "values": [
+ "place_changed",
+ "inappropriate",
+ "dangerous_place",
+ "hidden_ad",
+ "copied_content",
+ "false_info",
+ "other",
+ "low_rating"
+ ]
+ },
+ "public.route_revision_status": {
+ "name": "route_revision_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "pending",
+ "changes_requested",
+ "rejected",
+ "published"
+ ]
+ },
+ "public.route_status": {
+ "name": "route_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "private",
+ "pending",
+ "changes_requested",
+ "published",
+ "needs_fix",
+ "rejected",
+ "unpublished"
+ ]
+ },
+ "public.support_topic": {
+ "name": "support_topic",
+ "schema": "public",
+ "values": [
+ "question",
+ "problem",
+ "data",
+ "other"
+ ]
+ },
+ "public.transport": {
+ "name": "transport",
+ "schema": "public",
+ "values": [
+ "walk",
+ "bike",
+ "metro",
+ "car"
+ ]
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "public",
+ "values": [
+ "user",
+ "editor",
+ "moderator",
+ "admin"
+ ]
+ },
+ "public.verification_status": {
+ "name": "verification_status",
+ "schema": "public",
+ "values": [
+ "unverified",
+ "verified",
+ "stale",
+ "flagged",
+ "closed"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/api/src/db/generated/migrations/meta/_journal.json b/apps/api/src/db/generated/migrations/meta/_journal.json
index ed721b66..32557b69 100644
--- a/apps/api/src/db/generated/migrations/meta/_journal.json
+++ b/apps/api/src/db/generated/migrations/meta/_journal.json
@@ -106,6 +106,13 @@
"when": 1791990127311,
"tag": "0014_free_deleted_firebase_uid",
"breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "7",
+ "when": 1792121438641,
+ "tag": "0015_add_admin_actions",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts
index e603798d..e177458b 100644
--- a/apps/api/src/db/schema.ts
+++ b/apps/api/src/db/schema.ts
@@ -191,6 +191,9 @@ export const users = pgTable(
createdAt: createdAt(),
/** Soft delete, then purge (wiki Securite-et-RGPD). */
deletedAt: timestamp('deleted_at', { withTimezone: true }),
+ /** Set by the team (A-06): the account is refused everywhere, its routes stay published. */
+ suspendedAt: timestamp('suspended_at', { withTimezone: true }),
+ suspensionReason: text('suspension_reason'),
},
(t) => [
uniqueIndex('users_firebase_uid_active_idx')
@@ -670,6 +673,34 @@ export const routeUnlocks = pgTable(
],
);
+/**
+ * Journal of every write made from the admin (A-12, D-085): who, what, on what, the state before
+ * and after, and why. Rows are only ever inserted. `moderation_actions` stays the history of a
+ * route; this one covers every kind of target.
+ */
+export const adminActions = pgTable(
+ 'admin_actions',
+ {
+ id: id(),
+ /** Kept when the account of the actor is purged: the journal outlives it. */
+ actorId: uuid('actor_id').references(() => users.id, { onDelete: 'set null' }),
+ /** Stable name of the action: `route.publish`, `place.close`, `user.suspend`... */
+ action: text('action').notNull(),
+ /** Kind of the target (`route`, `place`, `user`, `setting`...) and its identifier. */
+ targetType: text('target_type').notNull(),
+ targetId: text('target_id').notNull(),
+ before: jsonb('before'),
+ after: jsonb('after'),
+ reason: text('reason'),
+ createdAt: createdAt(),
+ },
+ (t) => [
+ index('admin_actions_created_at_idx').on(t.createdAt),
+ index('admin_actions_target_idx').on(t.targetType, t.targetId),
+ index('admin_actions_actor_idx').on(t.actorId),
+ ],
+);
+
/** Thresholds (buckets, verification, rule limits), read by the API at startup. */
export const settings = pgTable('settings', {
key: text('key').primaryKey(),
diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts
index 3839ae48..9b26b2b1 100644
--- a/apps/api/src/errors.ts
+++ b/apps/api/src/errors.ts
@@ -16,10 +16,15 @@ const codeByStatus: Partial> = {
/**
* A client error the handler sends as is: `statusCode`, its `ApiError` code and `message`
- * (400 is `validation_error`).
+ * (400 is `validation_error`). `code` replaces the code of the status when the wiki names a
+ * finer one (`mfa_required`, `account_suspended`).
*/
-export function httpError(statusCode: 400 | 401 | 403 | 404 | 409, message: string) {
- return Object.assign(new Error(message), { statusCode });
+export function httpError(
+ statusCode: 400 | 401 | 403 | 404 | 409,
+ message: string,
+ code?: ApiErrorCode,
+) {
+ return Object.assign(new Error(message), { statusCode, apiCode: code });
}
/**
diff --git a/apps/api/src/routes/admin-guard.test.ts b/apps/api/src/routes/admin-guard.test.ts
new file mode 100644
index 00000000..fef985c8
--- /dev/null
+++ b/apps/api/src/routes/admin-guard.test.ts
@@ -0,0 +1,58 @@
+import { inArray } from 'drizzle-orm';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { buildApp } from '../app';
+import { TestTokenVerifier } from '../auth/test-verifier';
+import { users } from '../db/schema';
+import { uuidv7 } from '../db/uuid';
+import { testConfig } from '../test-config';
+
+// Every route of the admin, read from the OpenAPI document the API serves: a route added later
+// is checked without being listed here.
+const verifier = new TestTokenVerifier();
+const createdUids: string[] = [];
+let app: Awaited>;
+let adminRoutes: { method: string; url: string }[] = [];
+
+beforeAll(async () => {
+ app = await buildApp(testConfig(), { tokenVerifier: verifier });
+ const document = (await app.inject({ url: '/docs/json' })).json<{
+ paths: Record>;
+ }>();
+ adminRoutes = Object.entries(document.paths)
+ .filter(([path]) => path.startsWith('/v1/admin/'))
+ .flatMap(([path, operations]) =>
+ Object.keys(operations).map((method) => ({
+ method: method.toUpperCase(),
+ // Any identifier: the guard answers before the route reads it.
+ url: path.replaceAll(/\{[^}]+\}/g, uuidv7()),
+ })),
+ );
+});
+afterAll(async () => {
+ await app.db.delete(users).where(inArray(users.firebaseUid, createdUids));
+ await app.close();
+});
+
+describe('routes of the admin', () => {
+ it('exist', () => {
+ expect(adminRoutes.length).toBeGreaterThan(0);
+ });
+
+ it('all refuse a request without token', async () => {
+ for (const { method, url } of adminRoutes) {
+ const response = await app.inject({ method: method as 'GET', url, payload: {} });
+ expect({ method, url, status: response.statusCode }).toEqual({ method, url, status: 401 });
+ }
+ });
+
+ it('all refuse an account outside the team, even with a second factor', async () => {
+ const { token, uid } = verifier.issue({ secondFactor: 'totp' });
+ createdUids.push(uid);
+ await app.db.insert(users).values({ firebaseUid: uid, role: 'user' });
+ const headers = { authorization: `Bearer ${token}` };
+ for (const { method, url } of adminRoutes) {
+ const response = await app.inject({ method: method as 'GET', url, headers, payload: {} });
+ expect({ method, url, status: response.statusCode }).toEqual({ method, url, status: 403 });
+ }
+ });
+});
diff --git a/apps/api/src/routes/admin.test.ts b/apps/api/src/routes/admin.test.ts
new file mode 100644
index 00000000..dbf0d44e
--- /dev/null
+++ b/apps/api/src/routes/admin.test.ts
@@ -0,0 +1,137 @@
+import { AdminCounters, ApiError, type UserRole } from '@widoo/shared';
+import { eq, inArray } from 'drizzle-orm';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { buildApp } from '../app';
+import { TestTokenVerifier } from '../auth/test-verifier';
+import type { VerifiedIdentity } from '../auth/verifier';
+import { routes, users } from '../db/schema';
+import { seed, seedId } from '../db/seed';
+import { uuidv7 } from '../db/uuid';
+import { testConfig } from '../test-config';
+
+// Shared database, parallel files: unique Firebase uids and routes, only those are deleted.
+const verifier = new TestTokenVerifier();
+const createdUids: string[] = [];
+const createdRouteIds: string[] = [];
+const hour = 60 * 60 * 1000;
+const templateId = seedId('route:marais-gourmand');
+const farAway = { lat: 31, lng: 31 };
+const farAwayBounds = { west: 31, south: 31, east: 31.001, north: 31.001 };
+
+let app: Awaited>;
+beforeAll(async () => {
+ app = await buildApp(testConfig(), { tokenVerifier: verifier });
+ await seed(app.db);
+});
+afterAll(async () => {
+ await app.db.delete(routes).where(inArray(routes.id, createdRouteIds));
+ await app.db.delete(users).where(inArray(users.firebaseUid, createdUids));
+ await app.close();
+});
+
+/** A member of `role`, signed in with a second factor unless the identity says otherwise. */
+async function signIn(role: UserRole, identity: Partial = {}) {
+ const { token, uid } = verifier.issue({ secondFactor: 'totp', ...identity });
+ createdUids.push(uid);
+ await app.db.insert(users).values({ firebaseUid: uid, role });
+ return { authorization: `Bearer ${token}` };
+}
+
+const getCounters = (headers: Record = {}) =>
+ app.inject({ url: '/v1/admin/counters', headers });
+
+/** A pending route copied from the seed, last changed `hoursAgo` hours ago. */
+async function addPendingRoute(hoursAgo: number) {
+ // Always the same demo route of the seed, never a row another test file is using, and far
+ // from every zone the search tests look at.
+ const [template] = await app.db.select().from(routes).where(eq(routes.id, templateId));
+ if (!template) throw new Error('Demo route missing from the seed');
+ const id = uuidv7();
+ createdRouteIds.push(id);
+ await app.db.insert(routes).values({
+ ...template,
+ id,
+ shareToken: null,
+ startLocation: farAway,
+ bounds: farAwayBounds,
+ status: 'pending',
+ updatedAt: new Date(Date.now() - hoursAgo * hour),
+ });
+}
+
+describe('guard of /admin', () => {
+ it('answers 401 without a token', async () => {
+ const response = await getCounters();
+ expect(response.statusCode).toBe(401);
+ expect(ApiError.parse(response.json()).code).toBe('unauthorized');
+ });
+
+ it('answers 403 forbidden to an account outside the team', async () => {
+ const response = await getCounters(await signIn('user'));
+ expect(response.statusCode).toBe(403);
+ expect(ApiError.parse(response.json()).code).toBe('forbidden');
+ });
+
+ it('answers 403 mfa_required to a session without a second factor', async () => {
+ const response = await getCounters(await signIn('admin', { secondFactor: null }));
+ expect(response.statusCode).toBe(403);
+ expect(ApiError.parse(response.json()).code).toBe('mfa_required');
+ });
+
+ it('answers 403 mfa_required to a second factor other than the authenticator app', async () => {
+ const response = await getCounters(await signIn('admin', { secondFactor: 'phone' }));
+ expect(response.statusCode).toBe(403);
+ expect(ApiError.parse(response.json()).code).toBe('mfa_required');
+ });
+
+ it('asks for no second factor with the local Auth emulator, which has none', async () => {
+ const local = await buildApp(testConfig({ FIREBASE_AUTH_EMULATOR_HOST: '127.0.0.1:9099' }), {
+ tokenVerifier: verifier,
+ });
+ try {
+ const headers = await signIn('admin', { secondFactor: null });
+ const response = await local.inject({ url: '/v1/admin/counters', headers });
+ expect(response.statusCode).toBe(200);
+ } finally {
+ await local.close();
+ }
+ });
+
+ it('tells an account outside the team nothing about the second factor', async () => {
+ const response = await getCounters(await signIn('user', { secondFactor: null }));
+ expect(ApiError.parse(response.json()).code).toBe('forbidden');
+ });
+
+ it('answers 401 to a session older than 12 hours', async () => {
+ const headers = await signIn('admin', { authTime: new Date(Date.now() - 13 * hour) });
+ const response = await getCounters(headers);
+ expect(response.statusCode).toBe(401);
+ expect(ApiError.parse(response.json()).code).toBe('unauthorized');
+ });
+
+ it('lets in a member of the team with a second factor, never cached', async () => {
+ const response = await getCounters(await signIn('editor'));
+ expect(response.statusCode).toBe(200);
+ expect(response.headers['cache-control']).toBe('no-store');
+ });
+});
+
+describe('GET /v1/admin/counters', () => {
+ it('counts the pending routes, and those waiting for more than 72 hours', async () => {
+ const headers = await signIn('moderator');
+ const before = AdminCounters.parse((await getCounters(headers)).json()).moderation;
+ await addPendingRoute(1);
+ await addPendingRoute(80);
+
+ const after = AdminCounters.parse((await getCounters(headers)).json()).moderation;
+ expect(after).toEqual({
+ count: (before?.count ?? 0) + 2,
+ lateCount: (before?.lateCount ?? 0) + 1,
+ });
+ });
+
+ it('gives an editor no counter of the moderation', async () => {
+ const response = await getCounters(await signIn('editor'));
+ expect(AdminCounters.parse(response.json())).toEqual({});
+ });
+});
diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts
new file mode 100644
index 00000000..d42a4ef1
--- /dev/null
+++ b/apps/api/src/routes/admin.ts
@@ -0,0 +1,36 @@
+import { AdminCounters } from '@widoo/shared';
+import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
+import { readAdminCounters } from '../admin/counters';
+import { httpError } from '../errors';
+
+const security = [{ firebaseIdToken: [] }];
+
+/**
+ * Routes of the admin (wiki API › Admin). Each one names the section it belongs to: the guard
+ * checks the role against the matrix, the age of the session and the second factor. Never cached.
+ */
+export const adminRoutes: FastifyPluginAsyncZod = async (app) => {
+ app.addHook('onSend', async (_request, reply) => {
+ reply.header('cache-control', 'no-store');
+ });
+
+ app.get(
+ '/admin/counters',
+ {
+ // Every member of the team opens the dashboard; each counter is then filtered by role.
+ onRequest: app.requireAdminSection('dashboard'),
+ schema: {
+ tags: ['admin'],
+ summary: 'Counters of the navigation',
+ security,
+ response: { 200: AdminCounters },
+ },
+ },
+ async (request) => {
+ if (!request.user) {
+ throw httpError(401, 'Missing bearer token');
+ }
+ return readAdminCounters(app.db, request.user.role);
+ },
+ );
+};
diff --git a/apps/api/src/users/export.test.ts b/apps/api/src/users/export.test.ts
index 75fb9309..673e4692 100644
--- a/apps/api/src/users/export.test.ts
+++ b/apps/api/src/users/export.test.ts
@@ -168,6 +168,9 @@ describe('exportUserData', () => {
'user_featured_routes.user_id',
];
const setAside = [
+ // Journal of the writes of the team, kept when an account is purged (A-12): the work of a
+ // member of the team, not data of the person.
+ 'admin_actions.actor_id',
// Photos of the caller's routes and steps are exported with them.
'media.uploaded_by',
// Places belong to the shared catalog, not to the person who added them.
diff --git a/eslint.config.js b/eslint.config.js
index b0eab930..39c6ece8 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,5 +1,6 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier/flat';
+import nextPlugin from '@next/eslint-plugin-next';
import betterTailwindcss from 'eslint-plugin-better-tailwindcss';
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig, globalIgnores } from 'eslint/config';
@@ -19,6 +20,9 @@ export default defineConfig(
'**/.expo/',
'apps/mobile/ios/',
'apps/mobile/android/',
+ // Next.js: build output and the route types written by `next typegen`.
+ 'apps/admin/.next/',
+ 'apps/admin/next-env.d.ts',
]),
js.configs.recommended,
tseslint.configs.strict,
@@ -83,6 +87,63 @@ export default defineConfig(
files: ['apps/mobile/src/ui/svg/generated/Flag*.tsx'],
rules: { 'no-restricted-syntax': 'off' },
},
+ {
+ // Admin (Next.js) and the web components it shares: rules of hooks, and the Next.js rules
+ // for pages, links, images and fonts.
+ files: ['apps/admin/**/*.{ts,tsx}', 'packages/ui/**/*.{ts,tsx}'],
+ extends: [reactHooks.configs.flat.recommended],
+ plugins: { '@next/next': nextPlugin },
+ settings: { next: { rootDir: fileURLToPath(new URL('apps/admin/', import.meta.url)) } },
+ rules: {
+ ...nextPlugin.configs.recommended.rules,
+ ...nextPlugin.configs['core-web-vitals'].rules,
+ 'react-hooks/exhaustive-deps': 'error',
+ },
+ },
+ {
+ // Same rule as the app: styles from tokens.json only, here through the preset of @widoo/ui
+ // (theme of shadcn/ui read from the tokens).
+ files: ['apps/admin/src/**/*.{ts,tsx}', 'packages/ui/src/**/*.{ts,tsx}'],
+ plugins: { 'better-tailwindcss': betterTailwindcss },
+ settings: {
+ 'better-tailwindcss': {
+ tailwindConfig: fileURLToPath(new URL('apps/admin/tailwind.config.cjs', import.meta.url)),
+ selectors: [
+ { kind: 'attribute', name: '^className$', match: [{ type: 'strings' }] },
+ // Classes joined by `cn(...)`, and the variant tables of the components:
+ // `cva('base', { variants: { size: { small: '...' } } })`.
+ { kind: 'callee', name: '^cn$', match: [{ type: 'strings' }] },
+ {
+ kind: 'callee',
+ name: '^cva$',
+ match: [{ type: 'strings' }, { type: 'objectValues', path: '^variants\\.' }],
+ },
+ ],
+ },
+ },
+ rules: {
+ 'better-tailwindcss/no-unknown-classes': 'error',
+ 'better-tailwindcss/no-restricted-classes': [
+ 'error',
+ {
+ restrict: [
+ { pattern: '\\[.*\\]', message: 'Arbitrary value: use a token of tokens.json.' },
+ ],
+ },
+ ],
+ 'no-restricted-syntax': [
+ 'error',
+ {
+ selector: `Literal[value=${handWrittenColor}]`,
+ message: 'Color written by hand: use a color token of @widoo/tokens.',
+ },
+ {
+ selector: `TemplateElement[value.raw=${handWrittenColor}]`,
+ message: 'Color written by hand: use a color token of @widoo/tokens.',
+ },
+ ],
+ },
+ },
{
// CommonJS run by Node: the Tailwind preset generated by @widoo/tokens, and the Babel, Metro
// and Tailwind configuration of the app.
diff --git a/infra/admin.Dockerfile b/infra/admin.Dockerfile
new file mode 100644
index 00000000..ed3dafe5
--- /dev/null
+++ b/infra/admin.Dockerfile
@@ -0,0 +1,45 @@
+# Admin image for Cloud Run (apps/admin). Build from the repository root, one image per environment:
+# docker build -f infra/admin.Dockerfile -t widoo-admin \
+# --build-arg NEXT_PUBLIC_API_URL=https://api.staging.example \
+# --build-arg NEXT_PUBLIC_FIREBASE_API_KEY=... \
+# --build-arg NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=project.firebaseapp.com \
+# --build-arg NEXT_PUBLIC_FIREBASE_PROJECT_ID=project .
+# Next.js writes these values in the pages at build time, and the server reads them again for the
+# Content Security Policy. None is a secret: every NEXT_PUBLIC_ value is sent to the browser.
+# The Auth emulator is never set here: staging and production use their own Firebase project.
+# Cloud Run runs linux/amd64: add --platform linux/amd64 when building on Apple Silicon.
+# Base image pinned by digest, same as infra/api.Dockerfile.
+
+FROM node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 AS base
+ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH COREPACK_ENABLE_DOWNLOAD_PROMPT=0 NEXT_TELEMETRY_DISABLED=1
+RUN corepack enable
+WORKDIR /repo
+
+ARG NEXT_PUBLIC_API_URL
+ARG NEXT_PUBLIC_FIREBASE_API_KEY
+ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
+ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
+ NEXT_PUBLIC_FIREBASE_API_KEY=$NEXT_PUBLIC_FIREBASE_API_KEY \
+ NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=$NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN \
+ NEXT_PUBLIC_FIREBASE_PROJECT_ID=$NEXT_PUBLIC_FIREBASE_PROJECT_ID
+
+# Install the admin and its workspace dependencies, then build the standalone server.
+FROM base AS build
+COPY . .
+RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
+ pnpm install --frozen-lockfile --filter @widoo/admin... \
+ && pnpm --filter @widoo/admin build
+
+FROM base AS runtime
+ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=8080
+WORKDIR /app
+# The standalone output mirrors the monorepo: the server sits in apps/admin. Static files of the
+# build and the public folder are copied next to it. Owned by root, run as node: the process
+# cannot rewrite its own code.
+COPY --from=build /repo/apps/admin/.next/standalone ./
+COPY --from=build /repo/apps/admin/.next/static ./apps/admin/.next/static
+COPY --from=build /repo/apps/admin/public ./apps/admin/public
+USER node
+EXPOSE 8080
+CMD ["node", "apps/admin/server.js"]
diff --git a/package.json b/package.json
index 19517d6a..ae10019c 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@next/eslint-plugin-next": "16.3.8",
"@vitest/coverage-v8": "^5.0.1",
"eslint": "^10.11.0",
"eslint-config-prettier": "^10.1.8",
diff --git a/packages/api-client/src/client.test.ts b/packages/api-client/src/client.test.ts
index 0521be1e..7c9d6a36 100644
--- a/packages/api-client/src/client.test.ts
+++ b/packages/api-client/src/client.test.ts
@@ -174,6 +174,29 @@ describe('authenticated routes', () => {
expect(isAccountDeleted(error)).toBe(false);
});
+ it('says when the session is refused even with a fresh token', async () => {
+ const onUnauthorized = vi.fn();
+ const responses = [
+ unauthorized('Admin session expired'),
+ unauthorized('Admin session expired'),
+ ];
+ const { client } = setup(responses, { getToken: async () => 'token-1', onUnauthorized });
+ await failure(client.getMe());
+ expect(onUnauthorized).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps quiet about a deleted account and a request sent without token', async () => {
+ const onUnauthorized = vi.fn();
+ const deleted = setup([unauthorized('Account deleted')], {
+ getToken: async () => 'token-1',
+ onUnauthorized,
+ });
+ await failure(deleted.client.getMe());
+ const signedOut = setup([unauthorized('Missing bearer token')], { onUnauthorized });
+ await failure(signedOut.client.getMe());
+ expect(onUnauthorized).not.toHaveBeenCalled();
+ });
+
it('does not retry a deleted account, flagged for sign-out', async () => {
const getToken = vi.fn(async () => 'token-1');
const { client, fetch } = setup([unauthorized('Account deleted')], { getToken });
diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts
index d73aaa39..c846e22d 100644
--- a/packages/api-client/src/client.ts
+++ b/packages/api-client/src/client.ts
@@ -1,4 +1,5 @@
import {
+ AdminCounters,
AppConfig,
GeocodeResult,
LegalDocument,
@@ -223,6 +224,19 @@ export function createApiClient(options: ApiClientOptions) {
exportMyData: (signal?: AbortSignal) =>
request({ method: 'GET', path: '/v1/me/export', schema: MyDataExport, auth: true, signal }),
+ /**
+ * `GET /v1/admin/counters`: what waits in each section of the admin the caller opens. Team
+ * only: 403 for any other account, and without a second factor (`mfa_required`).
+ */
+ getAdminCounters: (signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: '/v1/admin/counters',
+ schema: AdminCounters,
+ auth: true,
+ signal,
+ }),
+
/** `DELETE /v1/me`: anonymizes the account; its token is refused afterwards. */
deleteMe: () => requestEmpty({ method: 'DELETE', path: '/v1/me', auth: true }),
};
diff --git a/packages/api-client/src/transport.ts b/packages/api-client/src/transport.ts
index bfbc60b7..72d25e24 100644
--- a/packages/api-client/src/transport.ts
+++ b/packages/api-client/src/transport.ts
@@ -19,6 +19,12 @@ export interface ApiClientOptions {
timeoutMs?: number;
/** The global `fetch` by default; injected by tests. */
fetch?: typeof fetch;
+ /**
+ * Called when an authenticated request is still refused (401) with a fresh token: the API has
+ * ended the session (the 12 hours of the admin, a revoked account). Not for a deleted account,
+ * flagged on its own by `isAccountDeleted`.
+ */
+ onUnauthorized?: () => void;
}
export interface Request {
@@ -107,6 +113,9 @@ export function createTransport(options: ApiClientOptions) {
response = await attempt(request, refreshed);
}
if (response.status < 200 || response.status > 299) {
+ if (response.status === 401 && token) {
+ options.onUnauthorized?.();
+ }
throw httpFailure(response.status, response.json);
}
return response;
diff --git a/packages/shared/src/admin-sections.test.ts b/packages/shared/src/admin-sections.test.ts
new file mode 100644
index 00000000..c1c7c031
--- /dev/null
+++ b/packages/shared/src/admin-sections.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest';
+import {
+ adminSections,
+ adminSectionsOf,
+ canOpenAdminSection,
+ countedAdminSections,
+ isTeamRole,
+ type AdminSection,
+} from './admin-sections';
+import { taxonomies, type UserRole } from './taxonomies';
+
+// The matrix of the wiki (Admin-Web › Rôles), row by row: editor, moderator, admin.
+const matrix: Record = {
+ dashboard: [true, true, true],
+ moderation: [false, true, true],
+ reports: [false, true, true],
+ catalog: [true, true, true],
+ users: [false, true, true],
+ aiDrafts: [true, false, true],
+ translations: [true, true, true],
+ affiliation: [true, false, true],
+ b2bClients: [true, false, true],
+ settings: [false, false, true],
+ actionLog: [false, false, true],
+ support: [false, true, true],
+};
+
+describe('canOpenAdminSection', () => {
+ it.each(adminSections)('follows the matrix of the wiki for %s', (section) => {
+ const teamRoles = ['editor', 'moderator', 'admin'] as const;
+ expect(teamRoles.map((role) => canOpenAdminSection(role, section))).toEqual(matrix[section]);
+ });
+
+ it.each(adminSections)('refuses %s to a user', (section) => {
+ expect(canOpenAdminSection('user', section)).toBe(false);
+ });
+});
+
+describe('adminSectionsOf', () => {
+ it('gives nothing to a user', () => {
+ expect(adminSectionsOf('user')).toEqual([]);
+ });
+
+ it('gives an editor its sections only, in the order of the admin', () => {
+ expect(adminSectionsOf('editor')).toEqual([
+ 'dashboard',
+ 'catalog',
+ 'aiDrafts',
+ 'translations',
+ 'affiliation',
+ 'b2bClients',
+ ]);
+ });
+
+ it('gives a moderator its sections only', () => {
+ expect(adminSectionsOf('moderator')).toEqual([
+ 'dashboard',
+ 'moderation',
+ 'reports',
+ 'catalog',
+ 'users',
+ 'translations',
+ 'support',
+ ]);
+ });
+
+ it('gives an admin every section', () => {
+ expect(adminSectionsOf('admin')).toEqual([...adminSections]);
+ });
+});
+
+describe('isTeamRole', () => {
+ it('holds for every role but user', () => {
+ const teamRoles = taxonomies.userRoles.filter((role: UserRole) => isTeamRole(role));
+ expect(teamRoles).toEqual(['editor', 'moderator', 'admin']);
+ });
+});
+
+describe('countedAdminSections', () => {
+ it('lists the counters of the navigation (wiki Admin-Web › Modèles communs)', () => {
+ expect(countedAdminSections).toEqual([
+ 'moderation',
+ 'reports',
+ 'support',
+ 'aiDrafts',
+ 'translations',
+ ]);
+ });
+});
diff --git a/packages/shared/src/admin-sections.ts b/packages/shared/src/admin-sections.ts
new file mode 100644
index 00000000..82e752f2
--- /dev/null
+++ b/packages/shared/src/admin-sections.ts
@@ -0,0 +1,71 @@
+import type { UserRole } from './taxonomies';
+
+/** Roles of the Widoo team: the only ones the admin lets in (wiki Admin-Web › Rôles). */
+export type TeamRole = Exclude;
+
+/** Sections of the admin, in the order of its screens A-02 to A-13, the dashboard (A-05) first. */
+export const adminSections = [
+ 'dashboard',
+ 'moderation',
+ 'reports',
+ 'catalog',
+ 'users',
+ 'aiDrafts',
+ 'translations',
+ 'affiliation',
+ 'b2bClients',
+ 'settings',
+ 'actionLog',
+ 'support',
+] as const;
+
+export type AdminSection = (typeof adminSections)[number];
+
+/**
+ * Who opens each section (wiki Admin-Web › Rôles): a role is listed as soon as the matrix gives
+ * it anything there, read only included. What a role may do inside a section (a moderator only
+ * reads the catalog, an editor only sees the team's routes) is decided by each route of the API.
+ * Written out for `admin` too, rather than derived from `hasRole`: `editor` and `moderator` do
+ * not imply each other, and the matrix is the reference.
+ */
+const sectionRoles = {
+ dashboard: ['editor', 'moderator', 'admin'],
+ moderation: ['moderator', 'admin'],
+ reports: ['moderator', 'admin'],
+ catalog: ['editor', 'moderator', 'admin'],
+ users: ['moderator', 'admin'],
+ aiDrafts: ['editor', 'admin'],
+ translations: ['editor', 'moderator', 'admin'],
+ affiliation: ['editor', 'admin'],
+ b2bClients: ['editor', 'admin'],
+ settings: ['admin'],
+ actionLog: ['admin'],
+ support: ['moderator', 'admin'],
+} as const satisfies Record;
+
+/** True for a member of the team: `editor`, `moderator` or `admin`. */
+export function isTeamRole(role: UserRole): role is TeamRole {
+ return role !== 'user';
+}
+
+/** True when `role` opens `section`. The API decides; the admin only hides what is refused. */
+export function canOpenAdminSection(role: UserRole, section: AdminSection): boolean {
+ const roles: readonly UserRole[] = sectionRoles[section];
+ return roles.includes(role);
+}
+
+/** The sections `role` opens, in the order of `adminSections`; none for a `user`. */
+export function adminSectionsOf(role: UserRole): AdminSection[] {
+ return adminSections.filter((section) => canOpenAdminSection(role, section));
+}
+
+/** Sections whose navigation entry carries a "to handle" counter (`GET /admin/counters`). */
+export const countedAdminSections = [
+ 'moderation',
+ 'reports',
+ 'support',
+ 'aiDrafts',
+ 'translations',
+] as const satisfies readonly AdminSection[];
+
+export type CountedAdminSection = (typeof countedAdminSections)[number];
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index ab566006..94a2be57 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,3 +1,4 @@
+export * from './admin-sections';
export * from './analytics';
export * from './app-version';
export { arrondissementLabel } from './arrondissement';
@@ -17,6 +18,7 @@ export * from './taxonomies';
export * from './thresholds';
export { hasRole } from './roles';
export { zoneMinSpanM, zoneSearchBox } from './zone';
+export * from './schemas/admin';
export * from './schemas/common';
export * from './schemas/config';
export * from './schemas/error';
diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts
new file mode 100644
index 00000000..7c6f5b3d
--- /dev/null
+++ b/packages/shared/src/schemas/admin.ts
@@ -0,0 +1,20 @@
+import { z } from 'zod';
+import { countedAdminSections } from '../admin-sections';
+
+/** Hours after which something waiting in the admin is late (wiki Creation-et-Moderation). */
+export const adminLateAfterHours = 72;
+
+/** What waits in a section: how many, and how many of them for more than 72 hours. */
+export const AdminCounter = z.object({
+ count: z.number().int().nonnegative(),
+ lateCount: z.number().int().nonnegative(),
+});
+export type AdminCounter = z.infer;
+
+/**
+ * Body of `GET /admin/counters`: the counters of the navigation, for the sections the role of
+ * the caller opens. A section is absent when the role does not open it, or while the API does
+ * not count it yet.
+ */
+export const AdminCounters = z.partialRecord(z.enum(countedAdminSections), AdminCounter);
+export type AdminCounters = z.infer;
diff --git a/packages/shared/src/schemas/error.ts b/packages/shared/src/schemas/error.ts
index ff7b9938..b72afc2a 100644
--- a/packages/shared/src/schemas/error.ts
+++ b/packages/shared/src/schemas/error.ts
@@ -12,6 +12,10 @@ export const apiErrorCodes = [
'premium_required',
/** 410 of a withdrawn route, with `details.removedAt` (`RouteRemovedDetails`). */
'route_removed',
+ /** 403 on `/admin/*`: the session was opened without a second factor (D-087). */
+ 'mfa_required',
+ /** 403 everywhere: the account was suspended by the team (wiki Admin-Web › A-06). */
+ 'account_suspended',
'internal_error',
] as const;
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 00000000..5707905a
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@widoo/ui",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./tailwind-preset": "./tailwind-preset.cjs"
+ },
+ "scripts": {
+ "lint": "eslint .",
+ "typecheck": "tsc",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@phosphor-icons/react": "^2.1.10",
+ "@radix-ui/react-dialog": "^1.2.0",
+ "@radix-ui/react-dropdown-menu": "^2.1.25",
+ "@radix-ui/react-slot": "~1.4.0",
+ "@radix-ui/react-tooltip": "^1.3.0",
+ "@widoo/tokens": "workspace:*",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "sonner": "^2.0.8"
+ },
+ "peerDependencies": {
+ "react": "19.2.3",
+ "tailwindcss": "3.4.19"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.4",
+ "@types/react": "~19.2.2",
+ "react": "19.2.3",
+ "tailwindcss": "3.4.19"
+ }
+}
diff --git a/packages/ui/shadcn-theme.cjs b/packages/ui/shadcn-theme.cjs
new file mode 100644
index 00000000..5994bac6
--- /dev/null
+++ b/packages/ui/shadcn-theme.cjs
@@ -0,0 +1,40 @@
+/**
+ * Theme of shadcn/ui for Widoo: each variable of shadcn names the color token of tokens.json it
+ * takes its value from (wiki Ecrans › Admin web, « Thème shadcn/ui », validated on 2026-10-09,
+ * D-086). No color is written here: the preset reads the values in @widoo/tokens.
+ */
+const shadcnColorTokens = {
+ background: 'bg',
+ foreground: 'ink',
+ card: 'bg',
+ 'card-foreground': 'ink',
+ popover: 'bg',
+ 'popover-foreground': 'ink',
+ primary: 'blue',
+ 'primary-foreground': 'on-blue',
+ secondary: 'surface',
+ 'secondary-foreground': 'ink',
+ muted: 'surface',
+ 'muted-foreground': 'muted',
+ accent: 'surface',
+ 'accent-foreground': 'ink',
+ destructive: 'error-ink',
+ // White on the red of a destructive button, 6.6:1 (board « Contrastes vérifiés »).
+ 'destructive-foreground': 'bg',
+ border: 'line',
+ input: 'control-border',
+ ring: 'ink',
+ sidebar: 'surface',
+ 'sidebar-foreground': 'ink',
+ 'sidebar-accent': 'bg',
+ 'sidebar-accent-foreground': 'ink',
+ 'sidebar-primary': 'blue',
+ 'sidebar-primary-foreground': 'on-blue',
+ 'sidebar-border': 'line',
+ 'sidebar-ring': 'ink',
+};
+
+/** Blocks, tiles, panels and tables: 12 px, the `--radius` of shadcn (`radius-photo`). */
+const shadcnRadiusToken = 'photo';
+
+module.exports = { shadcnColorTokens, shadcnRadiusToken };
diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx
new file mode 100644
index 00000000..958fc4f2
--- /dev/null
+++ b/packages/ui/src/components/Button.tsx
@@ -0,0 +1,55 @@
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
+import type { ComponentProps } from 'react';
+import { cn } from '../lib/cn';
+
+/**
+ * Buttons of the web interfaces (wiki Ecrans › Admin web, « Formes et tailles »): pills of 36 px,
+ * 32 px small, 44 px on the sign-in page. `destructive` is kept for the confirmation that repeats
+ * the action; elsewhere a destructive action opens with `destructiveOutline`.
+ */
+const buttonVariants = cva(
+ 'inline-flex shrink-0 items-center justify-center gap-8 whitespace-nowrap rounded-pill transition-colors disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ primary: 'bg-primary text-primary-foreground hover:bg-blue-ink',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-line',
+ outline: 'border border-input bg-background text-foreground hover:bg-accent',
+ ghost: 'text-foreground hover:bg-accent',
+ destructive: 'bg-destructive text-destructive-foreground',
+ destructiveOutline: 'border border-destructive bg-background text-destructive',
+ },
+ size: {
+ small: 'min-h-32 px-12 text-label-strong',
+ medium: 'min-h-chip-h px-16 text-control',
+ large: 'min-h-touch-min px-20 text-button',
+ },
+ },
+ defaultVariants: { variant: 'primary', size: 'medium' },
+ },
+);
+
+export type ButtonProps = ComponentProps<'button'> &
+ VariantProps & {
+ /** Renders the child (a link) with the look of the button instead of a `button`. */
+ asChild?: boolean;
+ };
+
+export function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ type = 'button',
+ ...props
+}: ButtonProps) {
+ const Component = asChild ? Slot : 'button';
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/ConfirmDialog.tsx b/packages/ui/src/components/ConfirmDialog.tsx
new file mode 100644
index 00000000..106465e8
--- /dev/null
+++ b/packages/ui/src/components/ConfirmDialog.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { XIcon } from '@phosphor-icons/react/dist/ssr';
+import type { FormEvent, ReactNode } from 'react';
+import { Button } from './Button';
+
+type ConfirmDialogProps = {
+ isOpen: boolean;
+ /** Closing by the cross, Escape, the veil or « Annuler »: nothing is done. */
+ onCancel: () => void;
+ /** The form is valid and submitted: the caller does the action. */
+ onConfirm: () => void;
+ /** Question that names the action: « Fermer ce lieu ? ». */
+ title: string;
+ /** What the action does, in a sentence: the dialog always names the effect. */
+ description: string;
+ /** Fields of the confirmation, the reason first (`ReasonFields`). */
+ children?: ReactNode;
+ cancelLabel: string;
+ closeLabel: string;
+ /** Repeats the action: « Fermer le lieu », never « Confirmer ». */
+ confirmLabel: string;
+ /** The action is running: the buttons wait for its answer. */
+ isBusy?: boolean;
+};
+
+/**
+ * Confirmation of a destructive action (wiki Ecrans › Admin web, « Confirmation »): a window on
+ * a veil, the only place where a button is red. Its fields are a form: required ones block the
+ * confirmation by themselves.
+ */
+export function ConfirmDialog({
+ isOpen,
+ onCancel,
+ onConfirm,
+ title,
+ description,
+ children,
+ cancelLabel,
+ closeLabel,
+ confirmLabel,
+ isBusy = false,
+}: ConfirmDialogProps) {
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ onConfirm();
+ };
+
+ return (
+ !isNowOpen && onCancel()}>
+
+
+
+
+ {title}
+
+
+
+
+
+ {description}
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/CountBadge.tsx b/packages/ui/src/components/CountBadge.tsx
new file mode 100644
index 00000000..1df759b3
--- /dev/null
+++ b/packages/ui/src/components/CountBadge.tsx
@@ -0,0 +1,27 @@
+import { cn } from '../lib/cn';
+
+type CountBadgeProps = {
+ count: number;
+ /** Something has been waiting for too long: red, never by the color alone (see `label`). */
+ isLate?: boolean;
+ /** What a screen reader says instead of the number: « 12 parcours à relire, dont 2 en retard ». */
+ label: string;
+ className?: string;
+};
+
+/** Number of things to handle, in a pill next to a navigation entry or on its icon. */
+export function CountBadge({ count, isLate = false, label, className }: CountBadgeProps) {
+ return (
+
+ {count}
+
+ );
+}
diff --git a/packages/ui/src/components/DetailPanel.tsx b/packages/ui/src/components/DetailPanel.tsx
new file mode 100644
index 00000000..267e2dec
--- /dev/null
+++ b/packages/ui/src/components/DetailPanel.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import { XIcon } from '@phosphor-icons/react/dist/ssr';
+import { useEffect, useRef, type ReactNode } from 'react';
+
+type DetailPanelProps = {
+ /** Title of the record, also the name of the panel for screen readers. */
+ title: string;
+ /** Status badge above the title. */
+ status?: ReactNode;
+ /** Label of the close button: « Fermer ». */
+ closeLabel: string;
+ /** Closes the panel: the cross, or Escape. The caller gives the focus back to the row. */
+ onClose: () => void;
+ /** Actions pinned at the bottom of the panel. */
+ actions?: ReactNode;
+ children: ReactNode;
+};
+
+/**
+ * Detail of a record, 400 px on the right of its list, which stays in view (wiki Ecrans › Admin
+ * web, « Panneau de détail »). It takes the focus when it opens; Escape closes it.
+ */
+export function DetailPanel({
+ title,
+ status,
+ closeLabel,
+ onClose,
+ actions,
+ children,
+}: DetailPanelProps) {
+ const panel = useRef(null);
+
+ useEffect(() => {
+ panel.current?.focus();
+ }, [title]);
+
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu.tsx
new file mode 100644
index 00000000..46bb5be8
--- /dev/null
+++ b/packages/ui/src/components/DropdownMenu.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import * as MenuPrimitive from '@radix-ui/react-dropdown-menu';
+import type { ComponentProps } from 'react';
+import { cn } from '../lib/cn';
+
+export const DropdownMenu = MenuPrimitive.Root;
+export const DropdownMenuTrigger = MenuPrimitive.Trigger;
+
+/** Floating panel of a menu, under its trigger and aligned on its right edge by default. */
+export function DropdownMenuContent({
+ className,
+ align = 'end',
+ sideOffset = 8,
+ ...props
+}: ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+/** An action of a menu: a row as high as a button, highlighted under the pointer or the focus. */
+export function DropdownMenuItem({
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+
+ );
+}
+
+export function DropdownMenuSeparator() {
+ return ;
+}
diff --git a/packages/ui/src/components/Field.tsx b/packages/ui/src/components/Field.tsx
new file mode 100644
index 00000000..8279b7b7
--- /dev/null
+++ b/packages/ui/src/components/Field.tsx
@@ -0,0 +1,44 @@
+import { WarningCircleIcon } from '@phosphor-icons/react/dist/ssr';
+import type { ReactNode } from 'react';
+import { Label } from './Input';
+
+type FieldProps = {
+ label: string;
+ /** `id` of the control, which the label points to. */
+ htmlFor: string;
+ /** Marked with a red star; the control itself carries `required`. */
+ isRequired?: boolean;
+ /** Help under the control, tied to it by `aria-describedby={`${htmlFor}-hint`}`. */
+ hint?: string;
+ /** Why the value is refused; replaces the hint and is announced at once. */
+ error?: string;
+ /** The control: `Input`, `Textarea` or `Select`. */
+ children: ReactNode;
+};
+
+/** A control with its label, its help and its error, stacked. */
+export function Field({ label, htmlFor, isRequired = false, hint, error, children }: FieldProps) {
+ return (
+
+
+ {children}
+ {error ? (
+
+
+ {error}
+
+ ) : hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/ui/src/components/FilterChip.tsx b/packages/ui/src/components/FilterChip.tsx
new file mode 100644
index 00000000..5d336171
--- /dev/null
+++ b/packages/ui/src/components/FilterChip.tsx
@@ -0,0 +1,35 @@
+import { CheckIcon } from '@phosphor-icons/react/dist/ssr';
+import type { ComponentProps } from 'react';
+import { cn } from '../lib/cn';
+
+type FilterChipProps = Omit, 'aria-pressed'> & {
+ label: string;
+ /** The filter applies: dark pill with a check, as the active chips of the app. */
+ isActive: boolean;
+ /** How many rows the filter holds, after the label. */
+ count?: number;
+};
+
+/** A filter of a list, as a pill of 32 px that toggles. */
+export function FilterChip({ label, isActive, count, className, ...props }: FilterChipProps) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/Input.tsx b/packages/ui/src/components/Input.tsx
new file mode 100644
index 00000000..430eb1d5
--- /dev/null
+++ b/packages/ui/src/components/Input.tsx
@@ -0,0 +1,22 @@
+import type { ComponentProps } from 'react';
+import { cn } from '../lib/cn';
+import { fieldControlClasses } from '../lib/field-control';
+
+/** Text field of 40 px (wiki Ecrans › Admin web, « Formes et tailles »); 44 px with `large`. */
+export function Input({
+ className,
+ large = false,
+ ...props
+}: ComponentProps<'input'> & { large?: boolean }) {
+ return (
+
+ );
+}
+
+/** Label of a field, tied to it by `htmlFor`. */
+export function Label({ className, ...props }: ComponentProps<'label'>) {
+ return ;
+}
diff --git a/packages/ui/src/components/Notice.tsx b/packages/ui/src/components/Notice.tsx
new file mode 100644
index 00000000..c7aaeb32
--- /dev/null
+++ b/packages/ui/src/components/Notice.tsx
@@ -0,0 +1,31 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import type { ReactNode } from 'react';
+import { cn } from '../lib/cn';
+
+const noticeVariants = cva('flex gap-10 rounded-photo p-12 text-body-s', {
+ variants: {
+ tone: {
+ info: 'bg-blue-soft text-foreground',
+ error: 'bg-error-soft text-error-ink',
+ },
+ },
+});
+
+type NoticeProps = Required> & {
+ /** Icon of the message, never the only sign of its tone: the text says it too. */
+ icon?: ReactNode;
+ /** `alert` interrupts a screen reader (a refusal, an error); `status` waits its turn. */
+ role?: 'alert' | 'status';
+ children: ReactNode;
+ className?: string;
+};
+
+/** Framed message above a form or a list: an information on blue, a refusal or an error on red. */
+export function Notice({ tone, icon, role = 'status', children, className }: NoticeProps) {
+ return (
+
+ {icon}
+
{children}
+
+ );
+}
diff --git a/packages/ui/src/components/Pagination.tsx b/packages/ui/src/components/Pagination.tsx
new file mode 100644
index 00000000..9da226a5
--- /dev/null
+++ b/packages/ui/src/components/Pagination.tsx
@@ -0,0 +1,34 @@
+import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react/dist/ssr';
+import { Button } from './Button';
+
+type PaginationProps = {
+ /** Name of the navigation for screen readers: « Pages de la liste ». */
+ label: string;
+ previousLabel: string;
+ nextLabel: string;
+ /** Absent on the first page, and on the last one: the button is then disabled. */
+ onPrevious?: () => void;
+ onNext?: () => void;
+};
+
+/** « Précédent » and « Suivant » under a list paged by cursor: no page numbers, no total. */
+export function Pagination({
+ label,
+ previousLabel,
+ nextLabel,
+ onPrevious,
+ onNext,
+}: PaginationProps) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx
new file mode 100644
index 00000000..4f9741da
--- /dev/null
+++ b/packages/ui/src/components/Select.tsx
@@ -0,0 +1,25 @@
+import { CaretDownIcon } from '@phosphor-icons/react/dist/ssr';
+import type { ComponentProps } from 'react';
+import { cn } from '../lib/cn';
+import { fieldControlClasses } from '../lib/field-control';
+
+/**
+ * Choice in a short list (a codified reason), on the list of the browser: keyboard and screen
+ * readers work as everywhere else. Its options are `option` children.
+ */
+export function Select({ className, children, ...props }: ComponentProps<'select'>) {
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/SortableColumnHeader.tsx b/packages/ui/src/components/SortableColumnHeader.tsx
new file mode 100644
index 00000000..bcfb64f1
--- /dev/null
+++ b/packages/ui/src/components/SortableColumnHeader.tsx
@@ -0,0 +1,32 @@
+import { ArrowDownIcon, ArrowUpIcon, CaretUpDownIcon } from '@phosphor-icons/react/dist/ssr';
+import type { ReactNode } from 'react';
+import { cn } from '../lib/cn';
+
+export type SortDirection = 'asc' | 'desc';
+
+type SortableColumnHeaderProps = {
+ children: ReactNode;
+ /** Direction when the list is sorted by this column; null when it is sorted by another one. */
+ direction: SortDirection | null;
+ /** Sorts by this column, or reverses its direction. */
+ onSort: () => void;
+};
+
+/**
+ * Button inside the header cell of a column that sorts: an arrow on the sorted column, two
+ * carets on the others. The cell itself carries `aria-sort`.
+ */
+export function SortableColumnHeader({ children, direction, onSort }: SortableColumnHeaderProps) {
+ const Icon =
+ direction === 'asc' ? ArrowUpIcon : direction === 'desc' ? ArrowDownIcon : CaretUpDownIcon;
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/StatusBadge.tsx b/packages/ui/src/components/StatusBadge.tsx
new file mode 100644
index 00000000..d39124e6
--- /dev/null
+++ b/packages/ui/src/components/StatusBadge.tsx
@@ -0,0 +1,36 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import type { ReactNode } from 'react';
+import { cn } from '../lib/cn';
+
+const statusBadgeVariants = cva(
+ 'inline-flex items-center gap-4 whitespace-nowrap rounded-pill px-8 py-4 text-column-head',
+ {
+ variants: {
+ tone: {
+ info: 'bg-blue-soft text-blue-ink',
+ neutral: 'bg-secondary text-foreground',
+ success: 'bg-green-soft text-green-ink',
+ error: 'bg-error-soft text-error-ink',
+ alert: 'bg-alert-soft text-alert',
+ strong: 'bg-surface-strong text-on-strong',
+ },
+ },
+ },
+);
+
+type StatusBadgeProps = Required> & {
+ /** Icon of 16 px before the text; the text always says the status too. */
+ icon?: ReactNode;
+ children: ReactNode;
+ className?: string;
+};
+
+/** Status of a row or of a record, as a small pill: « En relecture », « Publié », « Refusé ». */
+export function StatusBadge({ tone, icon, children, className }: StatusBadgeProps) {
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx
new file mode 100644
index 00000000..6efab369
--- /dev/null
+++ b/packages/ui/src/components/Table.tsx
@@ -0,0 +1,65 @@
+import type { ComponentProps, KeyboardEvent } from 'react';
+import { cn } from '../lib/cn';
+
+/** A list as a table: header of 36 px, rows of 44 px separated by a line. */
+export function Table({ className, ...props }: ComponentProps<'table'>) {
+ return
;
+}
+
+/** Header cell of a column; `SortableColumnHeader` goes inside for a column that sorts. */
+export function TableHeaderCell({ className, ...props }: ComponentProps<'th'>) {
+ return (
+
+ );
+}
+
+type TableRowProps = Omit, 'onClick' | 'onKeyDown' | 'tabIndex'> & {
+ /** The row opens its record: it takes the focus, and Enter or Space opens it too. */
+ onOpen?: () => void;
+ /** The row whose record is open in the detail panel: blue ground. */
+ isOpen?: boolean;
+};
+
+/** A row of the table, opened by a click or from the keyboard when `onOpen` is given. */
+export function TableRow({ onOpen, isOpen = false, className, ...props }: TableRowProps) {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) {
+ event.preventDefault();
+ onOpen?.();
+ }
+ };
+ return (
+
+ );
+}
+
+type TableCellProps = ComponentProps<'td'> & {
+ /** The cell that names the row (a title, a subject): in 700. */
+ isStrong?: boolean;
+};
+
+/** A cell of 44 px; add `truncate` and a width for a text that may be long. */
+export function TableCell({ isStrong = false, className, ...props }: TableCellProps) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/TableSkeleton.tsx b/packages/ui/src/components/TableSkeleton.tsx
new file mode 100644
index 00000000..7043c6ec
--- /dev/null
+++ b/packages/ui/src/components/TableSkeleton.tsx
@@ -0,0 +1,32 @@
+import { TableCell, TableRow } from './Table';
+
+type TableSkeletonProps = {
+ /** Rows to draw while the list loads. */
+ rows: number;
+ /** Columns of the table. */
+ columns: number;
+ /** What a screen reader says once, instead of every gray bar: « Chargement de la liste ». */
+ label: string;
+};
+
+/** Loading state of a table: gray bars in place of the rows, inside its `tbody`. */
+export function TableSkeleton({ rows, columns, label }: TableSkeletonProps) {
+ return (
+ <>
+