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 ( + <> + + + + +