feat: scaffold frontend monorepo + first /login screen

Monorepo Turborepo (pnpm workspaces) avec 3 packages :

- apps/web : SPA React 19 + Vite 8 + Tailwind v4 (CSS-first)
  • TanStack Router (file-based, auto code-splitting), Query, Form
  • Radix primitives bruts + CVA + clsx + tailwind-merge
  • MSW pour mocker l'API tant qu'Adonis n'est pas branché
  • Polices Bricolage Grotesque + Inter self-hostées via fontsource
  • Tokens marque (rubis, cream, ink) exposés via @theme
  • Primitives maison : Gem, Brand, Eyebrow, Button, Input, Field
  • Route /login full flow : TanStack Form + Zod + mutation Query

- apps/api : Adonis 7 (kit api, scaffold via create-adonisjs)
  • Auth access tokens (Bearer) — cf. ADR-017
  • Tuyau core déjà câblé pour la génération de types
  • Routes /api/v1/auth/{signup,login} + /api/v1/account/{profile,logout}
  • Minimal — uniquement le pont front ↔ back

- packages/shared : types TS + schemas Zod + constantes
  • Source unique de vérité partagée api ↔ web
  • Domaines : User, Org, Auth, Client, Invoice, Plan

Tooling racine : Turbo, ESLint v9 flat, Prettier, husky, lint-staged.

CLAUDE.md et docs/decisions.md mis à jour avec ADR-014 à ADR-018
(stack, monorepo, PG existant, Bearer tokens, MinIO existant)
et le pointeur vers docs/tech/architecture.md.

Logo Rubis déplacé de landing/assets/ vers /assets/ (source unique
réutilisée par la landing et l'app).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 10:10:48 +02:00
parent 7c80c391f1
commit 8d3bab6a89
109 changed files with 14784 additions and 9 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

28
.gitignore vendored
View File

@ -1,4 +1,32 @@
.DS_Store .DS_Store
node_modules/ node_modules/
# Env files (never commit secrets)
.env .env
.env.local .env.local
.env.*.local
# Build artefacts
dist/
build/
*.tsbuildinfo
# Tooling caches
.turbo/
.cache/
coverage/
.eslintcache
# Adonis generated types (regenerated from API source)
apps/api/.adonisjs/
# Generated by TanStack Router
apps/web/src/routeTree.gen.ts
# Generated by MSW (vendored worker)
apps/web/public/mockServiceWorker.js
# Editor
.vscode/
.idea/
*.swp

4
.lintstagedrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"*.{ts,tsx,js,jsx}": ["prettier --write", "eslint --fix"],
"*.{json,md,css,yml,yaml}": ["prettier --write"]
}

10
.prettierignore Normal file
View File

@ -0,0 +1,10 @@
node_modules
dist
build
.turbo
.adonisjs
coverage
pnpm-lock.yaml
landing/index.html
**/routeTree.gen.ts
**/*.gen.ts

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf"
}

View File

@ -109,9 +109,20 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
## Stack technique ## Stack technique
À confirmer avec Arthur. Stack choisie mais pas encore documentée. *À remplir lors de la prochaine session technique.* | Couche | Choix | Source |
|---|---|---|
| Backend | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 |
| Frontend | **React + Vite** | ADR-014 |
| Routing client | **TanStack Router** | ADR-014 |
| State serveur | **TanStack Query** | ADR-014 |
| Base de données | **PostgreSQL** | ADR-014 |
| Hosting | **Proxmox + K3s** (perso) | ADR-014 |
| OCR provider | à benchmarker | ADR-020 (en attente) |
| Email outbound | à benchmarker | ADR-021 (en attente) |
Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider, email provider, hosting, jobs). **Architecture** : monorepo (`apps/api` + `apps/web` + `packages/shared`), API REST AdonisJS Bearer-auth, SPA React/Vite séparé, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant).
## Documents associés ## Documents associés
@ -128,6 +139,7 @@ Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider,
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP | | `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |
| `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) | | `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) |
| `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy | | `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy |
| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions |
| `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 | | `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 |
| `/k3s/` | Manifests Kubernetes (namespace, deployment, service) | | `/k3s/` | Manifests Kubernetes (namespace, deployment, service) |
| `/.claude/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) | | `/.claude/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) |

22
apps/api/.editorconfig Normal file
View File

@ -0,0 +1,22 @@
# http://editorconfig.org
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
insert_final_newline = unset
[**.min.js]
indent_style = unset
insert_final_newline = unset
[MakeFile]
indent_style = space
[*.md]
trim_trailing_whitespace = false

18
apps/api/.env.example Normal file
View File

@ -0,0 +1,18 @@
# Node
TZ=UTC
PORT=3333
HOST=localhost
NODE_ENV=development
# App
LOG_LEVEL=info
APP_KEY=
APP_URL=http://${HOST}:${PORT}
# Session
SESSION_DRIVER=cookie
#--------------------------------------------------------------------
# CORS (configure allowed origins for API access)
#--------------------------------------------------------------------
# CORS_ORIGIN=http://localhost:5173,http://localhost:3000

1
apps/api/.env.test Normal file
View File

@ -0,0 +1 @@
SESSION_DRIVER=memory

26
apps/api/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Dependencies and AdonisJS build
node_modules
build
tmp/*
!tmp/.gitkeep
# Secrets
.env
.env.local
.env.production.local
.env.development.local
# Frontend assets compiled code
public/assets
# Build tools specific
npm-debug.log
yarn-error.log
# Editors specific
.fleet
.idea
.vscode
# Platform specific
.DS_Store

3
apps/api/.prettierignore Normal file
View File

@ -0,0 +1,3 @@
.adonisjs
node_modules
build

27
apps/api/ace.js Normal file
View File

@ -0,0 +1,27 @@
/*
|--------------------------------------------------------------------------
| JavaScript entrypoint for running ace commands
|--------------------------------------------------------------------------
|
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
| PROCESS.
|
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
| Since, we cannot run TypeScript source code using "node" binary, we need
| a JavaScript entrypoint to run ace commands.
|
| This file registers the "ts-node/esm" hook with the Node.js module system
| and then imports the "bin/console.ts" file.
|
*/
/**
* Register hook to process TypeScript files using @poppinss/ts-exec
*/
import '@poppinss/ts-exec'
/**
* Import ace console entrypoint
*/
await import('./bin/console.js')

116
apps/api/adonisrc.ts Normal file
View File

@ -0,0 +1,116 @@
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Experimental flags
|--------------------------------------------------------------------------
|
| The following features will be enabled by default in the next major release
| of AdonisJS. You can opt into them today to avoid any breaking changes
| during upgrade.
|
*/
experimental: {},
/*
|--------------------------------------------------------------------------
| Commands
|--------------------------------------------------------------------------
|
| List of ace commands to register from packages. The application commands
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/session/commands'),
],
/*
|--------------------------------------------------------------------------
| Service providers
|--------------------------------------------------------------------------
|
| List of service providers to import and register when booting the
| application
|
*/
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/session/session_provider'),
() => import('@adonisjs/shield/shield_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('#providers/api_provider'),
],
/*
|--------------------------------------------------------------------------
| Preloads
|--------------------------------------------------------------------------
|
| List of modules to import before starting the application.
|
*/
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
],
/*
|--------------------------------------------------------------------------
| Tests
|--------------------------------------------------------------------------
|
| List of test suites to organize tests by their type. Feel free to remove
| and add additional suites.
|
*/
tests: {
suites: [
{
files: ['tests/unit/**/*.spec.{ts,js}'],
name: 'unit',
timeout: 2000,
},
{
files: ['tests/functional/**/*.spec.{ts,js}'],
name: 'functional',
timeout: 30000,
},
],
forceExit: false,
},
/*
|--------------------------------------------------------------------------
| Metafiles
|--------------------------------------------------------------------------
|
| A collection of files you want to copy to the build folder when creating
| the production build.
|
*/
metaFiles: [],
hooks: {
init: [
indexEntities({
transformers: { enabled: true },
}),
generateRegistry(),
],
},
})

View File

@ -0,0 +1,29 @@
import User from '#models/user'
import { loginValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer'
export default class AccessTokensController {
async store({ request, serialize }: HttpContext) {
const { email, password } = await request.validateUsing(loginValidator)
const user = await User.verifyCredentials(email, password)
const token = await User.accessTokens.create(user)
return serialize({
user: UserTransformer.transform(user),
token: token.value!.release(),
})
}
async destroy({ auth }: HttpContext) {
const user = auth.getUserOrFail()
if (user.currentAccessToken) {
await User.accessTokens.delete(user, user.currentAccessToken.identifier)
}
return {
message: 'Logged out successfully',
}
}
}

View File

@ -0,0 +1,18 @@
import User from '#models/user'
import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer'
export default class NewAccountController {
async store({ request, serialize }: HttpContext) {
const { fullName, email, password } = await request.validateUsing(signupValidator)
const user = await User.create({ fullName, email, password })
const token = await User.accessTokens.create(user)
return serialize({
user: UserTransformer.transform(user),
token: token.value!.release(),
})
}
}

View File

@ -0,0 +1,8 @@
import UserTransformer from '#transformers/user_transformer'
import type { HttpContext } from '@adonisjs/core/http'
export default class ProfileController {
async show({ auth, serialize }: HttpContext) {
return serialize(UserTransformer.transform(auth.getUserOrFail()))
}
}

View File

@ -0,0 +1,28 @@
import app from '@adonisjs/core/services/app'
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction
/**
* The method is used for handling errors and returning
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
return super.handle(error, ctx)
}
/**
* The method is used to report error to the logging service or
* the a third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}

View File

@ -0,0 +1,20 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AuthMiddleware {
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards)
return next()
}
}

View File

@ -0,0 +1,19 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import { type NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}

View File

@ -0,0 +1,9 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class ForceJsonResponseMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.request.request.headers.accept = 'application/json'
return next()
}
}

View File

@ -0,0 +1,16 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Silent auth middleware can be used as a global middleware to silent check
* if the user is logged-in or not.
*
* The request continues as usual, even when the user is not logged-in.
*/
export default class SilentAuthMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
await ctx.auth.check()
return next()
}
}

View File

@ -0,0 +1,18 @@
import { UserSchema } from '#database/schema'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
static accessTokens = DbAccessTokensProvider.forModel(User)
declare currentAccessToken?: AccessToken
get initials() {
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
if (first && last) {
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
}
return `${first.slice(0, 2)}`.toUpperCase()
}
}

View File

@ -0,0 +1,15 @@
import type User from '#models/user'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class UserTransformer extends BaseTransformer<User> {
toObject() {
return this.pick(this.resource, [
'id',
'fullName',
'email',
'createdAt',
'updatedAt',
'initials',
])
}
}

View File

@ -0,0 +1,26 @@
import vine from '@vinejs/vine'
/**
* Shared rules for email and password.
*/
const email = () => vine.string().email().maxLength(254)
const password = () => vine.string().minLength(8).maxLength(32)
/**
* Validator to use when performing self-signup
*/
export const signupValidator = vine.create({
fullName: vine.string().nullable(),
email: email().unique({ table: 'users', column: 'email' }),
password: password(),
passwordConfirmation: password().sameAs('password'),
})
/**
* Validator to use before validating user credentials
* during login
*/
export const loginValidator = vine.create({
email: email(),
password: vine.string(),
})

47
apps/api/bin/console.ts Normal file
View File

@ -0,0 +1,47 @@
/*
|--------------------------------------------------------------------------
| Ace entry point
|--------------------------------------------------------------------------
|
| The "console.ts" file is the entrypoint for booting the AdonisJS
| command-line framework and executing commands.
|
| Commands do not boot the application, unless the currently running command
| has "options.startApp" flag set to true.
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.ace()
.handle(process.argv.splice(2))
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

45
apps/api/bin/server.ts Normal file
View File

@ -0,0 +1,45 @@
/*
|--------------------------------------------------------------------------
| HTTP server entrypoint
|--------------------------------------------------------------------------
|
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
| server. Either you can run this file directly or use the "serve"
| command to run this file and monitor file changes
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.httpServer()
.start()
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

62
apps/api/bin/test.ts Normal file
View File

@ -0,0 +1,62 @@
/*
|--------------------------------------------------------------------------
| Test runner entrypoint
|--------------------------------------------------------------------------
|
| The "test.ts" file is the entrypoint for running tests using Japa.
|
| Either you can run this file directly or use the "test"
| command to run this file and monitor file changes.
|
*/
process.env.NODE_ENV = 'test'
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
import { configure, processCLIArgs, run } from '@japa/runner'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.testRunner()
.configure(async (app) => {
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
processCLIArgs(process.argv.splice(2))
configure({
...app.rcFile.tests,
...config,
...{
setup: runnerHooks.setup,
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
},
})
})
.run(() => run())
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

93
apps/api/config/app.ts Normal file
View File

@ -0,0 +1,93 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/core/http'
/**
* The app key is used for encrypting cookies, generating signed URLs,
* and by the "encryption" module.
*
* The encryption module will fail to decrypt data if the key is lost or
* changed. Therefore it is recommended to keep the app key secure.
*/
export const appKey = env.get('APP_KEY')
/**
* The app URL can be used in various places where you want to create absolute
* URLs to your application. For example, when sending emails, images should
* use absolute URLs.
*/
export const appUrl = env.get('APP_URL')
/**
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
/**
* Generate a unique request id for each incoming request.
* Useful to correlate logs and debug a request flow.
*/
generateRequestId: true,
/**
* Allow HTTP method spoofing via the "_method" form/query parameter.
* This lets HTML forms target PUT/PATCH/DELETE routes while still
* submitting with POST.
*/
allowMethodSpoofing: false,
/**
* Enabling async local storage will let you access HTTP context
* from anywhere inside your application.
*/
useAsyncLocalStorage: false,
/**
* Redirect configuration controls the behavior of
* response.redirect().back() and query string forwarding.
*/
redirect: {
/**
* When enabled, all redirects automatically carry over the current
* request's query string parameters to the redirect destination.
* Use withQs(false) to opt out for a specific redirect.
*/
forwardQueryString: true,
},
/**
* Manage cookies configuration. The settings for the session id cookie are
* defined inside the "config/session.ts" file.
*/
cookie: {
/**
* Restrict the cookie to a specific domain.
* Keep empty to use the current host.
*/
domain: '',
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Default lifetime for cookies managed by the HTTP layer.
*/
maxAge: '2h',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
})

50
apps/api/config/auth.ts Normal file
View File

@ -0,0 +1,50 @@
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
const authConfig = defineConfig({
/**
* Default guard used when no guard is explicitly specified.
*/
default: 'api',
guards: {
/**
* Token-based guard for stateless API authentication.
*/
api: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('#models/user'),
}),
}),
/**
* Session-based guard for browser authentication.
*/
web: sessionGuard({
/**
* Enable persistent login using remember-me tokens.
*/
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
/**
* Inferring types from the configured auth
* guards.
*/
declare module '@adonisjs/auth/types' {
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
}
declare module '@adonisjs/core/types' {
interface EventsList extends InferAuthEvents<Authenticators> {}
}

View File

@ -0,0 +1,78 @@
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
/**
* Parse request bodies for these HTTP methods.
* Keep this aligned with methods that receive payloads in your routes.
*/
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Config for the "application/x-www-form-urlencoded"
* content-type parser.
*/
form: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the form parser.
*/
types: ['application/x-www-form-urlencoded'],
},
/**
* Config for the JSON parser.
*/
json: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the JSON parser.
*/
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
/**
* Config for the "multipart/form-data" content-type parser.
* File uploads are handled by the multipart parser.
*/
multipart: {
/**
* Automatically process uploaded files into the system tmp directory.
*/
autoProcess: true,
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Routes where multipart processing is handled manually.
*/
processManually: [],
/**
* Maximum accepted payload size for multipart requests.
*/
limit: '20mb',
/**
* Content types handled by the multipart parser.
*/
types: ['multipart/form-data'],
},
})
export default bodyParserConfig

50
apps/api/config/cors.ts Normal file
View File

@ -0,0 +1,50 @@
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/cors'
/**
* Configuration options to tweak the CORS policy. The following
* options are documented on the official documentation website.
*
* https://docs.adonisjs.com/guides/security/cors
*/
const corsConfig = defineConfig({
/**
* Enable or disable CORS handling globally.
*/
enabled: true,
/**
* In development, allow every origin to simplify local front/backend setup.
* In production, keep an explicit allowlist (empty by default, so no
* cross-origin browser access is allowed until configured).
*/
origin: app.inDev ? true : [],
/**
* HTTP methods accepted for cross-origin requests.
*/
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Reflect request headers by default. Use a string array to restrict
* allowed headers.
*/
headers: true,
/**
* Response headers exposed to the browser.
*/
exposeHeaders: [],
/**
* Allow cookies/authorization headers on cross-origin requests.
*/
credentials: true,
/**
* Cache CORS preflight response for N seconds.
*/
maxAge: 90,
})
export default corsConfig

131
apps/api/config/database.ts Normal file
View File

@ -0,0 +1,131 @@
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/lucid'
const dbConfig = defineConfig({
/**
* Default connection used for all queries.
*/
connection: 'sqlite',
connections: {
/**
* SQLite connection (default).
*/
sqlite: {
client: 'better-sqlite3',
connection: {
filename: app.tmpPath('db.sqlite3'),
},
/**
* Required by Knex for SQLite defaults.
*/
useNullAsDefault: true,
migrations: {
/**
* Sort migration files naturally by filename.
*/
naturalSort: true,
/**
* Paths containing migration files.
*/
paths: ['database/migrations'],
},
schemaGeneration: {
/**
* Enable schema generation from Lucid models.
*/
enabled: true,
/**
* Custom schema rules file paths.
*/
rulesPaths: ['./database/schema_rules.js'],
},
},
/**
* PostgreSQL connection.
* Install package to switch: npm install pg
*/
// pg: {
// client: 'pg',
// connection: {
// host: env.get('DB_HOST'),
// port: env.get('DB_PORT'),
// user: env.get('DB_USER'),
// password: env.get('DB_PASSWORD'),
// database: env.get('DB_DATABASE'),
// },
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
/**
* MySQL / MariaDB connection.
* Install package to switch: npm install mysql2
*/
// mysql: {
// client: 'mysql2',
// connection: {
// host: env.get('DB_HOST'),
// port: env.get('DB_PORT'),
// user: env.get('DB_USER'),
// password: env.get('DB_PASSWORD'),
// database: env.get('DB_DATABASE'),
// },
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
/**
* Microsoft SQL Server connection.
* Install package to switch: npm install tedious
*/
// mssql: {
// client: 'mssql',
// connection: {
// server: env.get('DB_HOST'),
// port: env.get('DB_PORT'),
// user: env.get('DB_USER'),
// password: env.get('DB_PASSWORD'),
// database: env.get('DB_DATABASE'),
// },
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
/**
* libSQL (Turso) connection.
* Install package to switch: npm install @libsql/client
*/
// libsql: {
// client: 'libsql',
// connection: {
// url: env.get('LIBSQL_URL'),
// authToken: env.get('LIBSQL_AUTH_TOKEN'),
// },
// useNullAsDefault: true,
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
},
})
export default dbConfig

View File

@ -0,0 +1,34 @@
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
const encryptionConfig = defineConfig({
/**
* Default encryption driver used by the application.
*/
default: 'gcm',
list: {
gcm: drivers.aes256gcm({
/**
* Keys used for encryption/decryption.
* First key encrypts, all keys are tried for decryption.
*/
keys: [env.get('APP_KEY')],
/**
* Stable identifier for this driver.
*/
id: 'gcm',
}),
},
})
export default encryptionConfig
/**
* Inferring types for the list of encryptors you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}
}

75
apps/api/config/hash.ts Normal file
View File

@ -0,0 +1,75 @@
import { defineConfig, drivers } from '@adonisjs/core/hash'
/**
* Hashing configuration.
*
* This starter uses Node.js scrypt under the hood.
* Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
*/
const hashConfig = defineConfig({
/**
* Default hasher used by the application.
*/
default: 'scrypt',
list: {
/**
* Scrypt is memory-hard, which makes brute-force attacks more expensive.
*/
scrypt: drivers.scrypt({
/**
* Work factor (Node alias: N / cost).
* Higher values increase security and CPU+memory usage.
*
* Tuning guideline:
* - Start with 16384.
* - Increase gradually (for example 32768) and benchmark login/signup latency.
* - Keep values practical for your slowest production machine.
*
* Node constraint: value must be a power of two greater than 1.
*/
cost: 16384,
/**
* Block size (Node alias: r / blockSize).
* Increases memory and CPU linearly.
*
* Tuning guideline:
* - Keep 8 unless you have a measured reason to change it.
* - Raise only with benchmark data, because memory usage grows quickly.
*/
blockSize: 8,
/**
* Parallelization (Node alias: p / parallelization).
* Controls how many independent computations are performed.
*
* Tuning guideline:
* - Keep 1 for most applications.
* - Increase only after load testing if your infrastructure benefits from it.
*/
parallelization: 1,
/**
* Maximum memory limit in bytes (Node alias: maxmem / maxMemory).
* Hashing throws if the estimated memory usage is above this limit.
* Node documents the check as approximately: 128 * N * r > maxmem.
*
* Tuning guideline:
* - Keep this aligned with your cost/blockSize choices.
* - Increase carefully on memory-constrained environments.
*/
maxMemory: 33554432,
}),
},
})
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface HashersList extends InferHashers<typeof hashConfig> {}
}

51
apps/api/config/logger.ts Normal file
View File

@ -0,0 +1,51 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
/**
* Default logger name used by ctx.logger and app logger calls.
*/
default: 'app',
loggers: {
app: {
/**
* Toggle this logger on/off.
*/
enabled: true,
/**
* Logger name shown in log records.
*/
name: env.get('APP_NAME'),
/**
* Minimum level to output (trace, debug, info, warn, error, fatal).
*/
level: env.get('LOG_LEVEL'),
/**
* Use sync destination in non-production for immediate flush.
*/
destination: !app.inProduction ? await syncDestination() : undefined,
/**
* Configure where logs are written.
*/
transport: {
targets: [targets.file({ destination: 1 })],
},
},
},
})
export default loggerConfig
/**
* Inferring types for the list of loggers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
}

View File

@ -0,0 +1,78 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
const sessionConfig = defineConfig({
/**
* Enable or disable session support globally.
*/
enabled: true,
/**
* Cookie name storing the session identifier.
*/
cookieName: 'adonis-session',
/**
* When set to true, the session id cookie will be deleted
* once the user closes the browser.
*/
clearWithBrowser: false,
/**
* Define how long to keep the session data alive without
* any activity.
*/
age: '2h',
/**
* Configuration for session cookie and the
* cookie store.
*/
cookie: {
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
/**
* The store to use. Make sure to validate the environment
* variable in order to infer the store name without any
* errors.
*/
store: env.get('SESSION_DRIVER'),
/**
* List of configured stores. Refer documentation to see
* list of available stores and their config.
*/
stores: {
/**
* Store session data inside encrypted cookies.
*/
cookie: stores.cookie(),
/**
* Store session data inside the configured database.
*/
database: stores.database(),
},
})
export default sessionConfig

95
apps/api/config/shield.ts Normal file
View File

@ -0,0 +1,95 @@
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
/**
* Configure CSP policies for your app. Refer documentation
* to learn more.
*/
csp: {
/**
* Enable the Content-Security-Policy header.
*/
enabled: false,
/**
* Per-resource CSP directives.
*/
directives: {},
/**
* Report violations without blocking resources.
*/
reportOnly: false,
},
/**
* Configure CSRF protection options. Refer documentation
* to learn more.
*/
csrf: {
/**
* Enable CSRF token verification for state-changing requests.
*/
enabled: false,
/**
* Route patterns to exclude from CSRF checks.
* Useful for external webhooks or API endpoints.
*/
exceptRoutes: [],
/**
* Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients.
*/
enableXsrfCookie: true,
/**
* HTTP methods protected by CSRF validation.
*/
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
/**
* Control how your website should be embedded inside
* iframes.
*/
xFrame: {
/**
* Enable the X-Frame-Options header.
*/
enabled: true,
/**
* Block all framing attempts. Default value is DENY.
*/
action: 'DENY',
},
/**
* Force browser to always use HTTPS.
*/
hsts: {
/**
* Enable the Strict-Transport-Security header.
*/
enabled: true,
/**
* HSTS policy duration remembered by browsers.
*/
maxAge: '180 days',
},
/**
* Disable browsers from sniffing content types and rely only
* on the response content-type header.
*/
contentTypeSniffing: {
/**
* Enable X-Content-Type-Options: nosniff.
*/
enabled: true,
},
})
export default shieldConfig

View File

@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,31 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'auth_access_tokens'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table.string('type').notNullable()
table.string('name').nullable()
table.string('hash').notNullable()
table.text('abilities').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.timestamp('last_used_at').nullable()
table.timestamp('expires_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,61 @@
/**
* This file is automatically generated
* DO NOT EDIT manually
* Run "node ace migration:run" command to re-generate this file
*/
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export class AuthAccessTokenSchema extends BaseModel {
static $columns = [
'abilities',
'createdAt',
'expiresAt',
'hash',
'id',
'lastUsedAt',
'name',
'tokenableId',
'type',
'updatedAt',
] as const
$columns = AuthAccessTokenSchema.$columns
@column()
declare abilities: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column.dateTime()
declare expiresAt: DateTime | null
@column()
declare hash: string
@column({ isPrimary: true })
declare id: number
@column.dateTime()
declare lastUsedAt: DateTime | null
@column()
declare name: string | null
@column()
declare tokenableId: number
@column()
declare type: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'id', 'password', 'updatedAt'] as const
$columns = UserSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare email: string
@column()
declare fullName: string | null
@column({ isPrimary: true })
declare id: number
@column({ serializeAs: null })
declare password: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}

View File

@ -0,0 +1,3 @@
import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator'
export default {} satisfies SchemaRules

View File

@ -0,0 +1,2 @@
import { configApp } from '@adonisjs/eslint-config'
export default configApp()

79
apps/api/package.json Normal file
View File

@ -0,0 +1,79 @@
{
"name": "@rubis/api",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
"./data": "./.adonisjs/client/data.d.ts",
"./registry": "./.adonisjs/client/registry/index.ts"
},
"scripts": {
"start": "node bin/server.js",
"build": "node ace build",
"dev": "node ace serve --hmr",
"test": "node ace test",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
},
"imports": {
"#controllers/*": "./app/controllers/*.js",
"#exceptions/*": "./app/exceptions/*.js",
"#models/*": "./app/models/*.js",
"#mails/*": "./app/mails/*.js",
"#services/*": "./app/services/*.js",
"#listeners/*": "./app/listeners/*.js",
"#events/*": "./app/events/*.js",
"#generated/*": "./.adonisjs/server/*.js",
"#middleware/*": "./app/middleware/*.js",
"#transformers/*": "./app/transformers/*.js",
"#validators/*": "./app/validators/*.js",
"#providers/*": "./providers/*.js",
"#policies/*": "./app/policies/*.js",
"#abilities/*": "./app/abilities/*.js",
"#database/*": "./database/*.js",
"#tests/*": "./tests/*.js",
"#start/*": "./start/*.js",
"#config/*": "./config/*.js"
},
"devDependencies": {
"@adonisjs/assembler": "^8.4.0",
"@adonisjs/eslint-config": "^3.0.0",
"@adonisjs/prettier-config": "^1.4.5",
"@adonisjs/tsconfig": "^2.0.0",
"@japa/assert": "^4.2.0",
"@japa/plugin-adonisjs": "^5.2.0",
"@japa/runner": "^5.3.0",
"@poppinss/ts-exec": "^1.4.4",
"@types/luxon": "^3.7.1",
"@types/node": "~25.6.0",
"eslint": "^10.2.0",
"hot-hook": "^1.0.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.2",
"typescript": "~6.0.2",
"youch": "^4.1.1"
},
"dependencies": {
"@adonisjs/auth": "^10.1.0",
"@adonisjs/core": "^7.3.1",
"@adonisjs/cors": "^3.0.0",
"@adonisjs/lucid": "^22.4.2",
"@adonisjs/session": "^8.1.0",
"@adonisjs/shield": "^9.0.0",
"@japa/api-client": "^3.2.1",
"@tuyau/core": "^1.2.2",
"@vinejs/vine": "^4.3.1",
"better-sqlite3": "^12.9.0",
"luxon": "^3.7.2",
"reflect-metadata": "^0.2.2"
},
"hotHook": {
"boundaries": [
"./app/controllers/**/*.ts",
"./app/middleware/*.ts"
]
},
"prettier": "@adonisjs/prettier-config"
}

View File

@ -0,0 +1,69 @@
import { HttpContext } from '@adonisjs/core/http'
import { BaseSerializer } from '@adonisjs/core/transformers'
import { type SimplePaginatorMetaKeys } from '@adonisjs/lucid/types/querybuilder'
/**
* Custom serializer for API responses that ensures consistent JSON structure
* across all API endpoints. Wraps response data in a 'data' property and handles
* pagination metadata for Lucid ORM query results.
*/
class ApiSerializer extends BaseSerializer<{
Wrap: 'data'
PaginationMetaData: SimplePaginatorMetaKeys
}> {
/**
* Wraps all serialized data under this key in the response object.
* Example: { data: [...] } instead of returning raw arrays/objects
*/
wrap: 'data' = 'data'
/**
* Validates and defines pagination metadata structure for paginated responses.
* Ensures that pagination info from Lucid queries is properly formatted.
*
* @throws Error if metadata doesn't match Lucid's pagination structure
*/
definePaginationMetaData(metaData: unknown): SimplePaginatorMetaKeys {
if (!this.isLucidPaginatorMetaData(metaData)) {
throw new Error(
'Invalid pagination metadata. Expected metadata to contain Lucid pagination keys'
)
}
return metaData
}
}
/**
* Single instance of ApiSerializer used across the application
*/
const serializer = new ApiSerializer()
const serialize = Object.assign(
function (this: HttpContext, ...[data, resolver]: Parameters<ApiSerializer['serialize']>) {
return serializer.serialize(data, resolver ?? this.containerResolver)
},
{
withoutWrapping(
this: HttpContext,
...[data, resolver]: Parameters<ApiSerializer['serializeWithoutWrapping']>
) {
return serializer.serializeWithoutWrapping(data, resolver ?? this.containerResolver)
},
}
) as ApiSerializer['serialize'] & { withoutWrapping: ApiSerializer['serializeWithoutWrapping'] }
/**
* Adds the serialize method to all HttpContext instances.
* Usage in controllers: return ctx.serialize(data)
* This ensures all API responses follow the same structure with data wrapping.
*/
HttpContext.instanceProperty('serialize', serialize)
/**
* Module augmentation to add the serialize method to HttpContext.
* This allows controllers to use ctx.serialize() for consistent API responses.
*/
declare module '@adonisjs/core/http' {
export interface HttpContext {
serialize: typeof serialize
}
}

27
apps/api/start/env.ts Normal file
View File

@ -0,0 +1,27 @@
/*
|--------------------------------------------------------------------------
| Environment variables service
|--------------------------------------------------------------------------
|
| The `Env.create` method creates an instance of the Env service. The
| service validates the environment variables and also cast values
| to JavaScript data types.
|
*/
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
// Node
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
PORT: Env.schema.number(),
HOST: Env.schema.string({ format: 'host' }),
LOG_LEVEL: Env.schema.string(),
// App
APP_KEY: Env.schema.secret(),
APP_URL: Env.schema.string({ format: 'url', tld: false }),
// Session
SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),
})

49
apps/api/start/kernel.ts Normal file
View File

@ -0,0 +1,49 @@
/*
|--------------------------------------------------------------------------
| HTTP kernel file
|--------------------------------------------------------------------------
|
| The HTTP kernel file is used to register the middleware with the server
| or the router.
|
*/
import router from '@adonisjs/core/services/router'
import server from '@adonisjs/core/services/server'
/**
* The error handler is used to convert an exception
* to a HTTP response.
*/
server.errorHandler(() => import('#exceptions/handler'))
/**
* The server middleware stack runs middleware on all the HTTP
* requests, even if there is no route registered for
* the request URL.
*/
server.use([
() => import('#middleware/force_json_response_middleware'),
() => import('#middleware/container_bindings_middleware'),
() => import('@adonisjs/cors/cors_middleware'),
])
/**
* The router middleware stack runs middleware on all the HTTP
* requests with a registered route.
*/
router.use([
() => import('@adonisjs/core/bodyparser_middleware'),
() => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/shield/shield_middleware'),
() => import('@adonisjs/auth/initialize_auth_middleware'),
() => import('#middleware/silent_auth_middleware'),
])
/**
* Named middleware collection must be explicitly assigned to
* the routes or the routes group.
*/
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
})

37
apps/api/start/routes.ts Normal file
View File

@ -0,0 +1,37 @@
/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| The routes file is used for defining the HTTP routes.
|
*/
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('/', () => {
return { hello: 'world' }
})
router
.group(() => {
router
.group(() => {
router.post('signup', [controllers.NewAccount, 'store'])
router.post('login', [controllers.AccessTokens, 'store'])
})
.prefix('auth')
.as('auth')
router
.group(() => {
router.get('profile', [controllers.Profile, 'show'])
router.post('logout', [controllers.AccessTokens, 'destroy'])
})
.prefix('account')
.as('profile')
.use(middleware.auth())
})
.prefix('/api/v1')

View File

@ -0,0 +1,23 @@
/*
|--------------------------------------------------------------------------
| Validator file
|--------------------------------------------------------------------------
|
| The validator file is used for configuring global transforms for VineJS.
| The transform below converts all VineJS date outputs from JavaScript
| Date objects to Luxon DateTime instances, so that validated dates are
| ready to use with Lucid models and other parts of the app that expect
| Luxon DateTime.
|
*/
import { DateTime } from 'luxon'
import { VineDate } from '@vinejs/vine'
declare module '@vinejs/vine/types' {
interface VineGlobalTransforms {
date: DateTime
}
}
VineDate.transform((value) => DateTime.fromJSDate(value))

View File

@ -0,0 +1,56 @@
import { assert } from '@japa/assert'
import { apiClient } from '@japa/api-client'
import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import { dbAssertions } from '@adonisjs/lucid/plugins/db'
import testUtils from '@adonisjs/core/services/test_utils'
import { authApiClient } from '@adonisjs/auth/plugins/api_client'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
import type { Registry } from '../.adonisjs/client/registry/schema.d.ts'
/**
* This file is imported by the "bin/test.ts" entrypoint file
*/
declare module '@japa/api-client/types' {
interface RoutesRegistry extends Registry {}
}
/**
* This file is imported by the "bin/test.ts" entrypoint file
*/
/**
* Configure Japa plugins in the plugins array.
* Learn more - https://japa.dev/docs/runner-config#plugins-optional
*/
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
dbAssertions(app),
apiClient(),
sessionApiClient(app),
authApiClient(app),
]
/**
* Configure lifecycle function to run before and after all the
* tests.
*
* The setup functions are executed before all the tests
* The teardown functions are executed after all the tests
*/
export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
setup: [],
teardown: [],
}
/**
* Configure suites by tapping into the test suite instance.
* Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
return suite.setup(() => testUtils.httpServer().start())
}
}

0
apps/api/tmp/.gitkeep Normal file
View File

8
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
"compilerOptions": {
"rootDir": "./",
"jsx": "react",
"outDir": "./build"
}
}

View File

@ -0,0 +1,3 @@
VITE_API_URL=http://localhost:3333
VITE_PUBLIC_LANDING_URL=http://localhost:8080
VITE_USE_MOCKS=true

10
apps/web/.env.example Normal file
View File

@ -0,0 +1,10 @@
# URL de l'API AdonisJS. En dev local, MSW intercepte les requêtes —
# cette URL n'est utilisée que comme base path symbolique tant que le backend
# n'est pas branché.
VITE_API_URL=http://localhost:3333
# URL de la landing publique (lien retour depuis l'app)
VITE_PUBLIC_LANDING_URL=https://rubis.arthurbarre.fr
# Active MSW pour mocker l'API. Mettre à "false" pour taper le vrai backend.
VITE_USE_MOCKS=true

24
apps/web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
apps/web/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

22
apps/web/eslint.config.js Normal file
View File

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

18
apps/web/index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#9F1239" />
<meta
name="description"
content="Rubis Sur l'Ongle — Vos factures relancées toutes seules pendant que vous travaillez."
/>
<title>Rubis Sur l'Ongle</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

71
apps/web/package.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "@rubis/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"routes:generate": "tsr generate",
"prebuild": "tsr generate",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"pretypecheck": "tsr generate",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"msw:init": "msw init public --save"
},
"dependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.2.5",
"@fontsource-variable/inter": "^5.2.5",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@rubis/shared": "workspace:*",
"@tanstack/react-form": "^1.0.0",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "^1.114.3",
"@tanstack/react-router-devtools": "^1.114.3",
"@tuyau/client": "^0.2.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.475.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"sonner": "^1.7.4",
"tailwind-merge": "^3.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.1.0",
"@tanstack/router-cli": "^1.114.3",
"@tanstack/router-plugin": "^1.114.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^26.0.0",
"msw": "^2.7.3",
"tailwindcss": "^4.1.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10",
"vitest": "^3.0.5"
},
"msw": {
"workerDirectory": "public"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,43 @@
import { Gem } from "./Gem";
import { cn } from "@/lib/utils";
/**
* Lockup horizontal : + "Rubis" (+ optionnel "sur l'ongle" en suffixe italique muted).
* À utiliser dans les headers, le sidebar, les emails.
*
* Cf. /docs/marque.md §2 et le pattern de la landing.
*/
type BrandProps = {
/** Affiche le suffixe "sur l'ongle" en italique muted. */
withSuffix?: boolean;
/** Taille du gem (le wordmark s'aligne dessus). */
gemSize?: number;
className?: string;
};
export function Brand({ withSuffix = false, gemSize = 22, className }: BrandProps) {
return (
<span
className={cn(
"inline-flex items-center gap-2.5 font-display text-ink",
"text-[19px] font-extrabold tracking-[-0.02em]",
className,
)}
>
<Gem size={gemSize} aria-label="Rubis Sur l'Ongle" />
<span className="leading-none">
Rubis
{withSuffix && (
<span
className={cn(
"ml-1 font-display italic font-medium text-ink-3",
"text-[12.5px] tracking-[-0.005em]",
)}
>
sur l&apos;ongle
</span>
)}
</span>
</span>
);
}

View File

@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
/**
* Le gem facetté, signature de la marque.
* SVG inline (pas une icône Lucide, jamais).
*
* 4 facettes suggérées + ligne médiane "table" du gem.
* Couleur : `currentColor` hérite du contexte. Default text-rubis.
*
* Cf. /docs/marque.md §2 (logo direction A) et §5 (icônes spéciales).
*/
type GemProps = {
/** Taille en pixels (carré). Default 22. */
size?: number;
/** Si true, applique un drop-shadow doux rubis pour les héros. */
glow?: boolean;
className?: string;
"aria-label"?: string;
};
export function Gem({ size = 22, glow = false, className, ...props }: GemProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"text-rubis shrink-0",
glow && "[filter:drop-shadow(0_4px_8px_rgba(159,18,57,0.3))]",
className,
)}
role={props["aria-label"] ? "img" : "presentation"}
aria-hidden={props["aria-label"] ? undefined : "true"}
{...props}
>
<polygon points="100,10 190,100 100,190 10,100" fill="currentColor" />
{/* Table du gem */}
<line x1="10" y1="100" x2="190" y2="100" stroke="rgba(255,255,255,0.55)" strokeWidth="3" />
{/* Facettes hautes */}
<line x1="55" y1="55" x2="100" y2="100" stroke="rgba(255,255,255,0.4)" strokeWidth="2" />
<line x1="145" y1="55" x2="100" y2="100" stroke="rgba(255,255,255,0.4)" strokeWidth="2" />
</svg>
);
}

View File

@ -0,0 +1,103 @@
import { forwardRef } from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* Bouton primitive maison.
*
* Personnalité :
* - Border-radius 6px (sharper que la default Tailwind, cohérent landing)
* - Shadow rubis-teintée sur primary (pas de shadow plate générique)
* - Micro-translateY au hover (le bouton "soulève" légèrement)
* - Pas de focus ring bleu anneau rubis-glow discret
* - Variants explicites : primary / secondary / ghost / link / danger
*
* Composition via Radix Slot : `<Button asChild><Link to=…>` propage les styles
* sans wrapper supplémentaire.
*/
const buttonVariants = cva(
cn(
// Base
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-default",
"font-sans font-semibold transition-[transform,background,box-shadow,color] duration-150",
// États génériques
"disabled:pointer-events-none disabled:opacity-50",
// Focus ring discret rubis-glow — pas de blue ring browser default
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
"focus-visible:ring-offset-0",
),
{
variants: {
variant: {
primary: cn(
"bg-rubis text-white shadow-rubis",
"hover:bg-rubis-deep hover:-translate-y-px hover:shadow-rubis-hover",
"active:translate-y-0 active:shadow-rubis",
),
secondary: cn(
"bg-transparent text-ink border border-ink",
"hover:bg-ink hover:text-cream",
),
ghost: cn(
"bg-transparent text-ink",
"hover:bg-cream-2",
),
link: cn(
"bg-transparent text-rubis underline-offset-4 hover:underline px-0 py-0 h-auto",
"shadow-none",
),
danger: cn(
"bg-rubis-deep text-white",
"hover:bg-rubis-deep/90",
),
},
size: {
sm: "h-9 px-3 text-[13px]",
md: "h-11 px-[22px] py-[13px] text-[15px]",
lg: "h-12 px-7 text-base",
icon: "size-10 px-0",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
/** Si true, rend l'enfant en propageant les styles (cf. Radix Slot). */
asChild?: boolean;
/** État chargement : remplace le contenu par un spinner discret. */
loading?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
{...props}
>
{loading ? <ButtonSpinner /> : children}
</Comp>
);
},
);
Button.displayName = "Button";
function ButtonSpinner() {
return (
<span
aria-hidden="true"
className="inline-block size-4 animate-spin rounded-full border-2 border-current border-r-transparent"
/>
);
}
export { buttonVariants };

View File

@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
/**
* Eyebrow petit label majuscule rubis avec géométrique en préfixe.
* Marqueur de section, signature visible partout. Cf. /docs/marque.md §4.
*
* On préfère ça à un sous-titre classique : moins corporate, plus identifiable.
*/
type EyebrowProps = {
children: React.ReactNode;
/** Désactive le ◆ préfixe (rare — utile dans des contextes inline). */
withoutMark?: boolean;
/** Couleur custom (default rubis). Doit rester dans la palette marque. */
tone?: "rubis" | "ink";
className?: string;
};
export function Eyebrow({ children, withoutMark = false, tone = "rubis", className }: EyebrowProps) {
return (
<span
className={cn(
"inline-flex items-center gap-2 font-sans text-[11px] font-semibold uppercase leading-tight",
"tracking-[0.14em]",
tone === "rubis" ? "text-rubis" : "text-ink-3",
className,
)}
>
{!withoutMark && (
<span
aria-hidden="true"
className="inline-block size-[7px] rotate-45 bg-current"
/>
)}
{children}
</span>
);
}

View File

@ -0,0 +1,98 @@
import { useId } from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
/**
* Field groupe label + input + description + erreur.
* Compose des primitives Radix Label pour l'a11y propre.
*
* Usage :
* <Field label="Email" hint="Pro de préférence" error={...}>
* <Input type="email" />
* </Field>
*
* Le children reçoit automatiquement `id` et `aria-invalid` via cloneElement-free
* pattern : le `htmlFor` matche le `id` injecté dans children, mais on laisse
* l'utilisateur passer son input pour rester explicite.
*/
type FieldProps = {
label: string;
/** Description courte sous le label (≠ erreur). */
hint?: string;
/** Message d'erreur — affiché en rubis-deep. */
error?: string;
/** ID custom (sinon généré). À passer aussi sur l'input enfant. */
htmlFor?: string;
/** Si true, label visuellement masqué (mais lu par les screen readers). */
srOnlyLabel?: boolean;
children: React.ReactNode;
className?: string;
};
export function Field({
label,
hint,
error,
htmlFor,
srOnlyLabel = false,
children,
className,
}: FieldProps) {
const generatedId = useId();
const id = htmlFor ?? generatedId;
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<LabelPrimitive.Root
htmlFor={id}
className={cn(
"font-sans text-[13px] font-semibold text-ink",
srOnlyLabel && "sr-only",
)}
>
{label}
</LabelPrimitive.Root>
{hint && !error && (
<p id={hintId} className="text-[12.5px] text-ink-3 leading-snug">
{hint}
</p>
)}
<FieldContext.Provider value={{ id, describedBy: errorId ?? hintId, invalid: !!error }}>
{children}
</FieldContext.Provider>
{error && (
<p
id={errorId}
role="alert"
className="text-[12.5px] font-medium text-rubis-deep leading-snug"
>
{error}
</p>
)}
</div>
);
}
import { createContext, useContext } from "react";
type FieldContextValue = {
id: string;
describedBy: string | undefined;
invalid: boolean;
} | null;
const FieldContext = createContext<FieldContextValue>(null);
/**
* Hook pour les inputs enfants : récupère id, aria-describedby, aria-invalid
* depuis le Field parent. Permet d'éviter les props redondantes.
*/
export function useFieldContext(): NonNullable<FieldContextValue> {
const ctx = useContext(FieldContext);
if (!ctx) {
throw new Error("useFieldContext doit être utilisé dans un <Field>");
}
return ctx;
}

View File

@ -0,0 +1,36 @@
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
/**
* Input texte primitive minimale.
* - Pas de shadow, juste 1px line.
* - Focus = ring rubis-glow + border rubis (pas de blue browser default).
* - aria-invalid = état d'erreur visible sans dépendre de la lib de form.
*/
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type = "text", ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={cn(
// Base
"block w-full rounded-default border border-line bg-white px-3.5 py-3",
"font-sans text-[15px] text-ink placeholder:text-ink-3",
// Transitions
"transition-[border-color,box-shadow] duration-150",
// Focus
"focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
// États
"disabled:cursor-not-allowed disabled:bg-cream-2 disabled:text-ink-3",
"aria-[invalid=true]:border-rubis-deep aria-[invalid=true]:bg-rubis-glow/30",
className,
)}
{...props}
/>
);
},
);
Input.displayName = "Input";

102
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,102 @@
import { env } from "./env";
import { authStore } from "./auth";
/**
* Client HTTP minimal placeholder en attendant que le client Tuyau
* soit branché contre le code Adonis (cf. /docs/tech/frontend.md §6).
*
* Tant que MSW intercepte ou que l'API n'est pas prête, on tape via fetch
* sur baseUrl/api/v1/... et on sérialise/désérialise nous-mêmes.
*
* Une fois Tuyau opérationnel :
* import { createTuyau } from "@tuyau/client"
* import { api } from "@rubis/api/registry"
* export const tuyau = createTuyau({ api, baseUrl: env.VITE_API_URL, ... })
*
* On gardera ce fichier comme façade pour pouvoir centraliser :
* - l'auth header
* - le retry sur 401 (silent refresh)
* - la gestion d'erreur uniforme
*/
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly fieldErrors?: Record<string, string[]>,
) {
super(message);
this.name = "ApiError";
}
}
type RequestOptions = {
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
body?: unknown;
signal?: AbortSignal;
/** Si true, n'inclut pas le header Authorization (utile pour /auth/login). */
anonymous?: boolean;
};
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, signal, anonymous = false } = options;
const headers: Record<string, string> = {
Accept: "application/json",
};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (!anonymous && authStore.token) {
headers.Authorization = `Bearer ${authStore.token}`;
}
const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`;
const response = await fetch(url, {
method,
headers,
credentials: "include",
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
if (response.status === 204) {
return undefined as T;
}
const json = (await response.json().catch(() => null)) as
| { data?: T; errors?: Array<{ code: string; message: string; field?: string }> }
| null;
if (!response.ok) {
const firstError = json?.errors?.[0];
const fieldErrors: Record<string, string[]> | undefined = json?.errors?.reduce(
(acc, err) => {
if (err.field) {
acc[err.field] = [...(acc[err.field] ?? []), err.message];
}
return acc;
},
{} as Record<string, string[]>,
);
throw new ApiError(
response.status,
firstError?.code ?? "unknown",
firstError?.message ?? `Requête échouée (${response.status})`,
fieldErrors,
);
}
// Convention de réponse Adonis : { data: ..., meta?: ... }
return (json?.data ?? json) as T;
}
export const api = {
get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "GET" }),
post: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "POST", body }),
patch: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "PATCH", body }),
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "DELETE" }),
};

60
apps/web/src/lib/auth.ts Normal file
View File

@ -0,0 +1,60 @@
import { useSyncExternalStore } from "react";
import type { User } from "@rubis/shared";
/**
* Auth store token en mémoire seulement (pas localStorage).
* Le refresh token vit en cookie httpOnly côté API, invisible ici.
* Cf. ADR-017 et /docs/tech/frontend.md §7.
*/
type AuthState = {
accessToken: string | null;
user: User | null;
};
class AuthStore {
private state: AuthState = { accessToken: null, user: null };
private listeners = new Set<() => void>();
getSnapshot = (): AuthState => this.state;
get token(): string | null {
return this.state.accessToken;
}
get user(): User | null {
return this.state.user;
}
isAuthenticated(): boolean {
return this.state.accessToken !== null;
}
setSession(accessToken: string, user: User): void {
this.state = { accessToken, user };
this.notify();
}
clear(): void {
this.state = { accessToken: null, user: null };
this.notify();
}
subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn);
return () => {
this.listeners.delete(fn);
};
};
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}
export const authStore = new AuthStore();
/** Hook React pour s'abonner à l'état d'auth. */
export function useAuth(): AuthState {
return useSyncExternalStore(authStore.subscribe, authStore.getSnapshot, authStore.getSnapshot);
}

24
apps/web/src/lib/env.ts Normal file
View File

@ -0,0 +1,24 @@
import { z } from "zod";
/**
* Variables d'environnement exposées au client (préfixées VITE_).
* Validées au boot pour planter tôt si une variable manque.
*/
const envSchema = z.object({
VITE_API_URL: z.string().url(),
VITE_PUBLIC_LANDING_URL: z.string().url(),
VITE_USE_MOCKS: z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
});
const parsed = envSchema.safeParse(import.meta.env);
if (!parsed.success) {
// eslint-disable-next-line no-console
console.error("Variables d'environnement invalides :", parsed.error.flatten().fieldErrors);
throw new Error("Configuration invalide. Voir .env.example");
}
export const env = parsed.data;

View File

@ -0,0 +1,45 @@
import { format, formatDistanceToNowStrict, parseISO } from "date-fns";
import { fr } from "date-fns/locale";
import { MINUTES_PER_RUBIS } from "@rubis/shared";
/**
* Formateurs métier centralisés pour cohérence d'affichage.
* Cf. /docs/tech/frontend.md §9.
*/
/** "1 240,00 €" depuis un montant en centimes. */
export function formatEuros(cents: number): string {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
}).format(cents / 100);
}
/**
* Convertit un nombre de rubis en libellé "X h Y" (1 rubis = 10 min).
* Ex : 124 rubis "20 h 40".
*/
export function formatRubisToHours(rubis: number): string {
const totalMinutes = rubis * MINUTES_PER_RUBIS;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) return `${minutes} min`;
if (minutes === 0) return `${hours} h`;
return `${hours} h ${minutes.toString().padStart(2, "0")}`;
}
/** "5 mai 2026" depuis une date ISO. */
export function formatDate(iso: string): string {
return format(parseISO(iso), "d MMMM yyyy", { locale: fr });
}
/** "dans 3 jours" / "il y a 2 jours" depuis maintenant. */
export function formatRelativeDate(iso: string): string {
return formatDistanceToNowStrict(parseISO(iso), { locale: fr, addSuffix: true });
}
/** "5 mai" version courte. */
export function formatDateShort(iso: string): string {
return format(parseISO(iso), "d MMM", { locale: fr });
}

View File

@ -0,0 +1,26 @@
import type { InvoiceListFilters } from "@rubis/shared";
/**
* Convention queryKeys toutes les clés TanStack Query passent ici.
* Permet d'invalider précisément après une mutation.
*/
export const queryKeys = {
me: () => ["me"] as const,
invoices: {
all: () => ["invoices"] as const,
list: (filters: InvoiceListFilters) => ["invoices", "list", filters] as const,
detail: (id: string) => ["invoices", "detail", id] as const,
},
plans: {
all: () => ["plans"] as const,
detail: (slug: string) => ["plans", "detail", slug] as const,
},
clients: {
all: () => ["clients"] as const,
detail: (id: string) => ["clients", "detail", id] as const,
},
dashboard: {
kpis: () => ["dashboard", "kpis"] as const,
activity: () => ["dashboard", "activity"] as const,
},
} as const;

10
apps/web/src/lib/utils.ts Normal file
View File

@ -0,0 +1,10 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Combine et résout les conflits de classes Tailwind.
* Utilisation : `<div className={cn("p-4", maybeBig && "p-8")} />`
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

69
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,69 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen";
import { env } from "./lib/env";
import "./styles/app.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
async function enableMocking(): Promise<void> {
// import.meta.env.DEV est un booléen statique : Vite tree-shake la branche
// entière (et le chunk MSW avec) quand on build en mode production.
if (!import.meta.env.DEV || !env.VITE_USE_MOCKS) return;
const { worker } = await import("./mocks/browser");
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
// eslint-disable-next-line no-console
console.info(
"%c[MSW]%c Mocks API actifs — VITE_USE_MOCKS=true",
"background:#9F1239;color:white;padding:2px 6px;border-radius:3px;font-weight:600",
"color:#8A7F76",
);
}
function render(): void {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("#root introuvable dans index.html");
createRoot(rootEl).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
}
void enableMocking().then(render);

View File

@ -0,0 +1,5 @@
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
/** Service worker MSW — démarré conditionnellement depuis main.tsx. */
export const worker = setupWorker(...handlers);

84
apps/web/src/mocks/db.ts Normal file
View File

@ -0,0 +1,84 @@
/**
* Petite base in-memory pour les mocks MSW.
* Persiste dans sessionStorage pour survivre aux reload pendant le dev,
* mais reste isolée par onglet (pas d'interférence entre devs).
*/
import type { User } from "@rubis/shared";
const STORAGE_KEY = "rubis.mocks.db";
type Db = {
users: Array<User & { passwordHash: string }>;
};
const seedDb = (): Db => ({
users: [
{
id: "usr_demo",
email: "demo@rubis.fr",
fullName: "Arthur Démo",
organizationId: "org_demo",
signature: "Cordialement,\nArthur — Rubis Démo",
createdAt: new Date("2026-01-01").toISOString(),
updatedAt: new Date().toISOString(),
// mot de passe : "demo1234"
passwordHash: "demo1234",
},
],
});
function load(): Db {
if (typeof sessionStorage === "undefined") return seedDb();
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) {
const fresh = seedDb();
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
return fresh;
}
try {
return JSON.parse(raw) as Db;
} catch {
const fresh = seedDb();
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
return fresh;
}
}
function save(db: Db): void {
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(db));
}
}
export const mockDb = {
findUserByEmail(email: string): (User & { passwordHash: string }) | undefined {
const db = load();
return db.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
},
createUser(input: { email: string; password: string; fullName: string }): User {
const db = load();
const orgId = `org_${crypto.randomUUID()}`;
const user: User & { passwordHash: string } = {
id: `usr_${crypto.randomUUID()}`,
email: input.email,
fullName: input.fullName,
organizationId: orgId,
signature: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
passwordHash: input.password,
};
db.users.push(user);
save(db);
// Renvoie sans le hash
const { passwordHash: _ph, ...publicUser } = user;
return publicUser;
},
reset(): void {
if (typeof sessionStorage !== "undefined") {
sessionStorage.removeItem(STORAGE_KEY);
}
},
};

View File

@ -0,0 +1,137 @@
import { http, HttpResponse } from "msw";
import { loginSchema, registerSchema } from "@rubis/shared";
import { mockDb } from "../db";
const apiBase = "*/api/v1";
/** Génère un faux access token signé "à la main" — pas de vraie crypto. */
function fakeToken(userId: string): string {
return `mock.${userId}.${Date.now()}`;
}
function expiresInMinutes(min: number): string {
return new Date(Date.now() + min * 60_000).toISOString();
}
export const authHandlers = [
// POST /api/v1/auth/login
http.post(`${apiBase}/auth/login`, async ({ request }) => {
const json = await request.json();
const parsed = loginSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
const user = mockDb.findUserByEmail(parsed.data.email);
if (!user || user.passwordHash !== parsed.data.password) {
return HttpResponse.json(
{
errors: [
{ code: "invalid_credentials", message: "Email ou mot de passe incorrect" },
],
},
{ status: 401 },
);
}
const { passwordHash: _ph, ...publicUser } = user;
return HttpResponse.json({
data: {
accessToken: fakeToken(user.id),
expiresAt: expiresInMinutes(30),
user: publicUser,
},
});
}),
// POST /api/v1/auth/signup
http.post(`${apiBase}/auth/signup`, async ({ request }) => {
const json = await request.json();
const parsed = registerSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
if (mockDb.findUserByEmail(parsed.data.email)) {
return HttpResponse.json(
{
errors: [
{
code: "email_taken",
message: "Cet email est déjà utilisé",
field: "email",
},
],
},
{ status: 422 },
);
}
const user = mockDb.createUser(parsed.data);
return HttpResponse.json(
{
data: {
accessToken: fakeToken(user.id),
expiresAt: expiresInMinutes(30),
user,
},
},
{ status: 201 },
);
}),
// POST /api/v1/auth/refresh — pour l'instant, pas de refresh token côté mocks.
http.post(`${apiBase}/auth/refresh`, () => {
return HttpResponse.json(
{ errors: [{ code: "no_session", message: "Pas de session active" }] },
{ status: 401 },
);
}),
// POST /api/v1/account/logout
http.post(`${apiBase}/account/logout`, () => {
return new HttpResponse(null, { status: 204 });
}),
// GET /api/v1/account/profile
http.get(`${apiBase}/account/profile`, ({ request }) => {
const auth = request.headers.get("authorization");
if (!auth?.startsWith("Bearer mock.")) {
return HttpResponse.json(
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
{ status: 401 },
);
}
const userId = auth.split(".")[1];
if (!userId) {
return HttpResponse.json(
{ errors: [{ code: "invalid_token", message: "Token invalide" }] },
{ status: 401 },
);
}
// On retrouve l'utilisateur par id
const seed = mockDb.findUserByEmail("demo@rubis.fr");
if (!seed) {
return HttpResponse.json(
{ errors: [{ code: "not_found", message: "Utilisateur introuvable" }] },
{ status: 404 },
);
}
const { passwordHash: _ph, ...publicUser } = seed;
return HttpResponse.json({ data: publicUser });
}),
];

View File

@ -0,0 +1,4 @@
import { authHandlers } from "./auth";
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
export const handlers = [...authHandlers];

View File

@ -0,0 +1,74 @@
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { QueryClient } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { lazy, Suspense } from "react";
/**
* Devtools chargés uniquement en dev pour ne pas alourdir le bundle prod.
* Cf. /docs/tech/frontend.md §4.
*/
const TanStackRouterDevtools = import.meta.env.DEV
? lazy(() =>
import("@tanstack/react-router-devtools").then((m) => ({
default: m.TanStackRouterDevtools,
})),
)
: () => null;
const ReactQueryDevtools = import.meta.env.DEV
? lazy(() =>
import("@tanstack/react-query-devtools").then((m) => ({
default: m.ReactQueryDevtools,
})),
)
: () => null;
export interface RouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
notFoundComponent: NotFound,
});
function RootLayout() {
return (
<>
<Outlet />
<Toaster
position="bottom-right"
toastOptions={{
style: {
background: "var(--color-cream)",
color: "var(--color-ink)",
border: "1px solid var(--color-line)",
fontFamily: "var(--font-sans)",
},
}}
/>
{import.meta.env.DEV && (
<Suspense fallback={null}>
<TanStackRouterDevtools position="bottom-left" />
<ReactQueryDevtools buttonPosition="bottom-right" />
</Suspense>
)}
</>
);
}
function NotFound() {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-cream px-6 text-center">
<p className="font-display text-[80px] font-extrabold leading-none tracking-tight text-rubis">
404
</p>
<h1 className="mt-4 font-display text-3xl font-bold text-ink">
Cette page <em>s&apos;est perdue</em>.
</h1>
<p className="mt-3 max-w-sm text-ink-2">
Rien de grave. Retournez à l&apos;accueil et on repart de zéro.
</p>
</div>
);
}

View File

@ -0,0 +1,15 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { authStore } from "@/lib/auth";
/**
* Route racine "/" redirige selon l'état d'auth.
* Pas de UI propre : c'est juste un router.
*/
export const Route = createFileRoute("/")({
beforeLoad: () => {
if (authStore.isAuthenticated()) {
throw redirect({ to: "/login" }); // Sera /_app/ une fois le layout app prêt
}
throw redirect({ to: "/login" });
},
});

View File

@ -0,0 +1,194 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { ArrowRight } from "lucide-react";
import { z } from "zod";
import { loginSchema, type AuthSession, type LoginInput } from "@rubis/shared";
import { api, ApiError } from "@/lib/api";
import { authStore } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Brand } from "@/components/brand/Brand";
import { Gem } from "@/components/brand/Gem";
const searchSchema = z.object({
redirect: z.string().optional(),
});
export const Route = createFileRoute("/login")({
validateSearch: searchSchema,
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const loginMutation = useMutation({
mutationFn: async (input: LoginInput) =>
api.post<AuthSession>("/api/v1/auth/login", input, { anonymous: true }),
onSuccess: (session) => {
authStore.setSession(session.accessToken, session.user);
toast.success(`Bonjour ${session.user.fullName.split(" ")[0]}.`);
void navigate({ to: search.redirect ?? "/login" });
},
onError: (error: unknown) => {
if (error instanceof ApiError && error.status === 401) {
toast.error("Email ou mot de passe incorrect.");
return;
}
toast.error("Connexion impossible. Réessayez dans un instant.");
},
});
const form = useForm({
defaultValues: { email: "", password: "" } satisfies LoginInput,
validators: {
onChange: loginSchema,
},
onSubmit: async ({ value }) => {
await loginMutation.mutateAsync(value);
},
});
return (
<main className="min-h-screen bg-cream relative overflow-hidden">
{/* Glow rubis discret en haut-droite — signature visuelle (cohérent landing). */}
<div
aria-hidden="true"
className="pointer-events-none absolute top-[-180px] right-[-220px] size-[680px] rounded-full"
style={{
background:
"radial-gradient(circle, rgba(251,228,234,0.55), transparent 60%)",
}}
/>
<div className="relative z-10 mx-auto grid min-h-screen w-full max-w-[1180px] grid-cols-1 gap-16 px-6 py-12 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:px-8">
{/* Colonne gauche message marketing.
Décalée, dense, du caractère. Pas une carte centrée et fade. */}
<section className="order-2 lg:order-1 max-w-[520px]">
<Link to="/login" className="inline-block">
<Brand withSuffix />
</Link>
<Eyebrow className="mt-12">Bon retour</Eyebrow>
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-[-0.025em] text-ink lg:text-[52px]">
Vos factures vous <em>attendent</em>.
<br className="hidden sm:block" />
On reprend vous en étiez.
</h1>
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
Connectez-vous pour voir en sont vos relances, qui a payé,
et combien de temps Rubis vous a fait gagner cette semaine.
</p>
<ul className="mt-10 flex flex-wrap gap-x-6 gap-y-3 text-[12.5px] text-ink-3">
<li className="inline-flex items-center gap-2">
<Gem size={10} /> Hébergement souverain
</li>
<li className="inline-flex items-center gap-2">
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
Made in France
</li>
<li className="inline-flex items-center gap-2">
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
RGPD-friendly
</li>
</ul>
</section>
{/* Colonne droite — formulaire de connexion */}
<section className="order-1 lg:order-2">
<div className="mx-auto w-full max-w-[420px] rounded-card border border-line bg-white p-8 shadow-card">
<h2 className="font-display text-2xl font-semibold tracking-[-0.018em] text-ink">
Se connecter
</h2>
<p className="mt-1.5 text-[14px] text-ink-3">
Pas encore de compte ?{" "}
<Link
to="/login"
className="font-medium text-rubis underline-offset-4 hover:underline"
>
Créer un compte
</Link>
</p>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="mt-7 flex flex-col gap-5"
>
<form.Field name="email">
{(field) => (
<Field
label="Email"
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
<Input
id={field.name}
name={field.name}
type="email"
autoComplete="email"
autoFocus
placeholder="vous@entreprise.fr"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={field.state.meta.errors.length > 0}
/>
</Field>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<Field
label="Mot de passe"
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
<Input
id={field.name}
name={field.name}
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={field.state.meta.errors.length > 0}
/>
</Field>
)}
</form.Field>
<Button
type="submit"
size="md"
loading={loginMutation.isPending}
className="mt-1 w-full"
>
Continuer <ArrowRight size={16} aria-hidden="true" />
</Button>
<p className="text-center text-[12.5px] text-ink-3">
<Link to="/login" className="hover:text-rubis hover:underline">
Mot de passe oublié ?
</Link>
</p>
</form>
</div>
</section>
</div>
</main>
);
}

142
apps/web/src/styles/app.css Normal file
View File

@ -0,0 +1,142 @@
/* ============================================================================
* Rubis Sur l'Ongle feuille de styles racine
* Tailwind v4 (CSS-first) + tokens de marque (cf. /docs/marque.md)
* ========================================================================== */
@import "tailwindcss";
/* Polices self-hostées via fontsource (cf. /docs/tech/frontend.md §10) */
@import "@fontsource-variable/bricolage-grotesque";
@import "@fontsource-variable/inter";
/* ----------------------------------------------------------------------------
* Tokens de marque exposés en utilitaires Tailwind v4 via @theme.
* Source : /docs/marque.md §3, §4
* -------------------------------------------------------------------------- */
@theme {
/* === Couleurs rubis === */
--color-rubis: #9f1239;
--color-rubis-deep: #771328;
--color-rubis-light: #c9415c;
--color-rubis-glow: #fbe4ea;
/* === Neutres chauds (jamais de blanc/noir purs) === */
--color-cream: #faf7f2;
--color-cream-2: #f5efe7;
--color-line: #e8e0d6;
--color-ink: #1a1410;
--color-ink-2: #4f4640;
--color-ink-3: #8a7f76;
/* === Typographies === */
--font-display: "Bricolage Grotesque Variable", "Bricolage Grotesque", -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
/* === Border radius un peu plus tranchés que la default Tailwind ===
6px sur les éléments interactifs (cohérent avec la landing) */
--radius-sharp: 4px;
--radius-default: 6px;
--radius-soft: 10px;
--radius-card: 14px;
/* === Ombres rubis-teintées === */
--shadow-rubis: 0 2px 8px rgba(159, 18, 57, 0.25);
--shadow-rubis-hover: 0 6px 16px rgba(159, 18, 57, 0.35);
--shadow-card:
0 16px 40px -16px rgba(26, 20, 16, 0.18), 0 4px 8px -2px rgba(26, 20, 16, 0.06);
--shadow-soft: 0 4px 16px rgba(26, 20, 16, 0.04);
}
/* ----------------------------------------------------------------------------
* Globals
* -------------------------------------------------------------------------- */
@layer base {
html {
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
}
body {
background: var(--color-cream);
color: var(--color-ink);
font-family: var(--font-sans);
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.55;
}
/* Sélection rubis — petite signature partout */
::selection {
background: var(--color-rubis);
color: white;
}
/* Reset hr/fieldset minimal */
fieldset {
border: 0;
padding: 0;
margin: 0;
}
/* Chiffres alignés en colonnes — toujours préférable pour montants/dates */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
/* Italique rubis : convention typographique sur le mot-clé d'un titre */
em {
font-style: italic;
color: var(--color-rubis);
}
/* Anti-overflow sur écrans étroits */
body,
#root {
min-height: 100vh;
}
}
/* ----------------------------------------------------------------------------
* Utilitaires custom touche maison.
* Pas de helpers Tailwind par-dessus, juste de petites primitives marque.
* -------------------------------------------------------------------------- */
@utility eyebrow {
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
color: var(--color-rubis);
text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 8px;
line-height: 1.4;
}
@utility eyebrow-mark {
/* Le ◆ géométrique, en pseudo-élément carré tourné. Cohérent avec la landing. */
width: 7px;
height: 7px;
background: currentColor;
display: inline-block;
transform: rotate(45deg);
}
@utility shadow-rubis {
box-shadow: var(--shadow-rubis);
}
@utility shadow-rubis-hover {
box-shadow: var(--shadow-rubis-hover);
}
@utility shadow-card {
box-shadow: var(--shadow-card);
}
@utility shadow-soft {
box-shadow: var(--shadow-soft);
}

View File

@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

View File

@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx",
"moduleDetection": "force",
"useDefineForClassFields": true,
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"],
"@rubis/shared": ["../../packages/shared/src/index.ts"],
"@rubis/shared/*": ["../../packages/shared/src/*"]
}
},
"include": ["src"]
}

7
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"moduleDetection": "force",
"types": ["node"]
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

28
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,28 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
// Doit être déclaré AVANT react() (cf. doc TanStack Router).
TanStackRouterVite({
routesDirectory: "./src/routes",
generatedRouteTree: "./src/routeTree.gen.ts",
autoCodeSplitting: true,
}),
react(),
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
strictPort: true,
},
});

21
apps/web/vitest.config.ts Normal file
View File

@ -0,0 +1,21 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
/**
* Config Vitest séparée vitest@3 n'aligne pas encore ses types Vite avec
* vite@8 (rolldown). On déclare ici tout ce qui est test-only et on garde
* `vite.config.ts` propre pour le bundler.
*/
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});

View File

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 846 KiB

View File

@ -184,8 +184,6 @@
--- ---
## Décisions à venir (en attente)
## ADR-013 · Tagline V1 « Vos factures relancées toutes seules pendant que vous travaillez » ## ADR-013 · Tagline V1 « Vos factures relancées toutes seules pendant que vous travaillez »
- **Date** : 2026-05-05 - **Date** : 2026-05-05
@ -206,15 +204,118 @@
--- ---
## ADR-014 · Stack technique
- **Date** : 2026-05-05
- **Statut** : ✅ Validée
- **Contexte** : choix du stack initial pour démarrer le développement de Rubis. Bloquant pour les ADR suivants (domain model, repo layout, etc.).
- **Décision** :
- **Backend** : AdonisJS v7 (TypeScript, MVC, Lucid ORM, auth & jobs intégrés)
- **Frontend** : React + Vite + TanStack Router + TanStack Query
- **Base de données** : PostgreSQL
- **Hosting** : cluster Proxmox personnel + K3s (déjà en place pour la landing)
- **Rationale** :
- **AdonisJS v7** : "batteries included" en TS first — Lucid pour le SQL relationnel, Auth pour les sessions/tokens, Bouncer pour les permissions, Bull pour les jobs, Mailer pour l'email. Évite l'assemblage Express/Fastify + 12 libs.
- **React + Vite** : DX moderne, build rapide, écosystème massif. Maturité éprouvée pour un SaaS B2B.
- **TanStack Router** : routing client-side type-safe avec search params réactifs (idéal pour les filtres de la liste de factures). Pas couplé à un framework SSR.
- **TanStack Query** : cache + invalidation + retry + optimistic updates pour le state serveur. Évite Redux pour gérer du data API.
- **PostgreSQL** : transactions ACID indispensables pour la facturation, support JSON pour les payloads OCR, full-text search natif si besoin.
- **Proxmox + K3s** : maîtrise totale du runtime, coût marginal nul (infrastructure existante), pas de vendor lock-in. Le pipeline Gitea CI → registry → K3s rollout est déjà rodé sur la landing.
- **Conséquences architecturales** :
- **API REST séparée du SPA** (pas Inertia.js) — TanStack Router signifie que le client gère son propre routing. CORS et type-sharing entre back et front à organiser proprement.
- **TypeScript end-to-end** rend possible un dossier de types partagés (ex. `packages/shared/`) ou la génération auto via OpenAPI/Zod.
- Le build front (Vite) produit des assets statiques — soit servis par AdonisJS (`/public/build/`), soit par nginx en sidecar du pod, soit déployés en parallèle.
- **Alternatives écartées** :
- **Next.js / Remix** : trop SSR-centric pour un SaaS transactionnel. Le backend des frameworks meta-React reste plus faible que ce qu'offre Adonis.
- **Express ou Fastify + ORM (Prisma/Drizzle)** : assemblage de 12 libs pour atteindre ce qu'Adonis livre out-of-the-box.
- **MongoDB / NoSQL** : pas adapté aux relations facture-client-relance ni aux transactions financières.
- **Vercel / Render / Fly.io** : coûts récurrents évitables — Proxmox déjà en place et payé.
- **Décisions à formaliser dans la foulée** : repo layout (mono vs split), hébergement Postgres, auth flow (cookie vs token), file storage des PDF, domain model.
---
## ADR-015 · Repo layout : monorepo (apps/api + apps/web)
- **Date** : 2026-05-05
- **Statut** : ✅ Validée
- **Décision** : un seul repo Git, deux applications dans `apps/api/` (AdonisJS) et `apps/web/` (React/Vite), un dossier `packages/shared/` pour les types TS partagés. Workspaces gérés en pnpm.
- **Rationale** :
- Le type-sharing entre API et SPA est gratuit (un import depuis `@rubis/shared`)
- Une seule release coordonnée → pas de problèmes de version drift entre API et SPA
- CI/CD unique, scripts npm racine
- Solo dev TS = monorepo natural fit
- **Alternatives écartées** :
- **Deux repos séparés** : friction sur le type-sharing, releases à coordonner manuellement
- **Adonis monolithe avec front intégré (Inertia)** : aurait écarté TanStack Router (couplage Adonis routing). Adopté seulement si on retire TanStack Router de la stack.
---
## ADR-016 · PostgreSQL : LXC Proxmox existant
- **Date** : 2026-05-05
- **Statut** : ✅ Validée
- **Décision** : utiliser le serveur PostgreSQL déjà provisionné dans le LXC Proxmox d'Arthur. Créer une base `rubis` dédiée + un user `rubis_user` avec les permissions nécessaires.
- **Rationale** :
- Infrastructure existante, zéro coût d'infra additionnel
- Backups Proxmox existants (snapshots LXC) couvrent la base
- Performance native (pas la couche K3s), latence faible avec le cluster K3s sur le même réseau
- Hors cluster K3s = isolement des changements applicatifs (rollouts ne touchent jamais la DB)
- **À mettre en place** :
- Créer une `database rubis` + user dédié + grants minimum (CREATE/SELECT/INSERT/UPDATE/DELETE sur les tables de la base)
- Service K3s ou ExternalName pour exposer la connexion PG aux pods API
- Sauvegarde dump PG quotidienne dans MinIO (script cron côté LXC)
---
## ADR-017 · Auth : access tokens (Bearer)
- **Date** : 2026-05-05
- **Statut** : ✅ Validée
- **Décision** : authentification via **access tokens stateless** (Bearer header) via `@adonisjs/auth` v7. Pas de session cookie côté API.
- **Rationale** :
- Architecture API propre, agnostique du client (web V1, mobile V2, intégrations partenaires V3+ supportées sans refactoring)
- Tokens stockés en base (`auth_access_tokens` Adonis) → révocation possible côté admin
- Permet des **abilities/scopes par token** (utile en V2 pour donner un token "lecture seule" au comptable)
- **Implémentation** :
- **API** : middleware `auth` Adonis sur les routes protégées, sortie `Bearer token` à l'inscription/login
- **SPA** : token stocké en mémoire (variable de module/closure) + refresh token en cookie httpOnly pour persistance après reload, pour limiter le risque XSS
- **TTL** : access token 30 min, refresh token 30 jours (à valider au moment de l'implémentation)
- **Alternatives écartées** :
- **Session cookies @adonisjs/auth** : plus simple en V1 mais oblige à tout refaire au moment d'ajouter une API mobile/tiers
- **Tokens en localStorage seul** : exposé XSS (lecture par n'importe quel script tiers), pas safe pour des données financières
---
## ADR-018 · File storage : MinIO Proxmox existant
- **Date** : 2026-05-05
- **Statut** : ✅ Validée
- **Décision** : utiliser le MinIO déjà provisionné sur l'infra Proxmox d'Arthur pour stocker les PDF de factures et les pièces jointes.
- **Rationale** :
- Infrastructure existante, zéro coût additionnel
- API S3-compatible → utilise n'importe quelle lib AWS SDK
- Migration future vers cloud S3 (R2/B2) triviale (mêmes appels)
- Pre-signed URLs natives MinIO pour les téléchargements client-side sécurisés
- **À mettre en place** :
- Bucket `rubis-invoices` (PDF + images de factures)
- Bucket `rubis-attachments` (pièces jointes utilisateurs : signatures, logos)
- Credentials Access Key/Secret dédiés avec permissions limitées à ces 2 buckets
- Politique de retention : pas de purge auto en V1 (les factures sont des documents légaux)
- **Alternatives écartées** :
- **Local pod + PVC** : ne scale pas si plusieurs replicas, backup non trivial
- **Cloudflare R2 / Backblaze B2** : sortie de l'infra perso pour rien — MinIO existe déjà
---
## Décisions à venir (en attente) ## Décisions à venir (en attente)
| # | Sujet | Pourquoi en attente | | # | Sujet | Pourquoi en attente |
|---|---|---| |---|---|---|
| 014 | Stack technique (framework, DB, OCR provider, email provider, hosting) | À formaliser avec Arthur | | 019 | Domain model / DB schema (entités, relations, index) | À écrire — débloqué par 014-018, prochaine étape technique |
| 015 | Structure DB / domain model | Dépend de 014 | | 020 | Provider OCR (Mindee, Document AI, Textract, Tesseract self-hosted) | À benchmarker (coût + qualité sur factures FR) |
| 016 | Pricing exact (Free 5 factures ? Pro 19 € ?) | À tester avant figer | | 021 | Provider email outbound (Resend, Postmark, SendGrid, AWS SES) | À benchmarker (deliverability FR + prix au volume) |
| 017 | Provider OCR (Mindee, Document AI, Textract, open-source) | Dépend de coût/qualité — à benchmarker | | 022 | Pricing exact (Free 5 factures ? Pro 19 € ? Business 49 €) | À tester avant figer |
| 018 | Endpoint waitlist (Resend / Formspree / Tally / API perso) | Choix au déploiement de la landing | | 023 | Endpoint waitlist (Resend / Formspree / Tally / API Adonis perso) | Choix simple au moment du push de la landing |
--- ---

456
docs/tech/architecture.md Normal file
View File

@ -0,0 +1,456 @@
# Architecture technique — Rubis Sur l'Ongle
> Version : 0.1 · Dernière maj : 2026-05-05
> Décisions de référence : ADR-014 (stack), ADR-015 (repo), ADR-016 (PG), ADR-017 (auth), ADR-018 (storage). Voir `/docs/decisions.md`.
Ce document est la source de vérité technique. Quand le code et ce fichier divergent, on tranche en discussion et on met à jour ici.
---
## 1. Vue d'ensemble
```
┌────────────────────────────┐
│ Internet (HTTPS) │
└─────────────┬──────────────┘
┌─────────────▼──────────────┐
│ Traefik (Proxmox gateway) │
│ rubis.arthurbarre.fr │
│ app.rubis-sur-l-ongle.fr │
└─────────────┬──────────────┘
┌──────────────────┼──────────────────┐
│ │ │
┌────────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐
│ Pod: web-static │ │ Pod: api │ │ Pod: landing │
│ nginx + Vite │ │ AdonisJS │ │ (déjà déployé) │
│ build │ │ Node │ │ │
└──────────────────┘ └──────┬─────┘ └─────────────────┘
┌───────────────────────┼───────────────────────┐
│ │ │
┌────────▼─────────┐ ┌────────▼────────┐ ┌─────────▼────────┐
│ LXC: PostgreSQL │ │ LXC: MinIO │ │ Provider OCR │
│ (existant, pool │ │ (existant, │ │ (à benchmarker) │
│ Proxmox) │ │ S3-compatible) │ │ │
└──────────────────┘ └─────────────────┘ └──────────────────┘
┌────────▼─────────┐
│ Provider Email │
│ (à benchmarker) │
└──────────────────┘
```
**Composants** :
| Composant | Rôle | Hosting | Status |
|---|---|---|---|
| `apps/web` | SPA React Vite — interface utilisateur | nginx pod K3s (build statique) | À écrire |
| `apps/api` | API REST AdonisJS — logique métier, jobs, email | Pod Node K3s | À écrire |
| `packages/shared` | Types TS, schemas Zod, constantes communes | npm workspace local | À écrire |
| `landing` | Landing publique waitlist | nginx pod K3s | ✅ Déployé |
| PostgreSQL | Base de données métier | LXC Proxmox existant | ✅ En place |
| MinIO | Stockage PDF + pièces jointes (S3-compat) | LXC Proxmox existant | ✅ En place |
| Provider OCR | Extraction texte des factures | Externe (HTTPS) | ADR-020 à venir |
| Provider Email | Envoi outbound (relances + check-in) | Externe (HTTPS) | ADR-021 à venir |
---
## 2. Repo layout (monorepo)
```
rubis/
├── apps/
│ ├── api/ # AdonisJS v7 backend
│ │ ├── app/ # Controllers, models, services
│ │ ├── config/ # Auth, database, mail, queue
│ │ ├── database/
│ │ │ ├── migrations/
│ │ │ └── seeders/
│ │ ├── start/ # Routes, kernel
│ │ ├── tests/
│ │ ├── ace.js # CLI Adonis
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── web/ # React + Vite SPA
│ ├── src/
│ │ ├── routes/ # TanStack Router (file-based)
│ │ ├── components/
│ │ ├── lib/ # api client, query keys, utils
│ │ └── main.tsx
│ ├── public/
│ ├── index.html
│ ├── vite.config.ts
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ └── shared/ # Code partagé api ↔ web
│ ├── src/
│ │ ├── types/ # Types TS (DTOs API)
│ │ ├── schemas/ # Schemas Zod (validation)
│ │ └── constants/ # Énums, règles métier
│ ├── package.json
│ └── tsconfig.json
├── landing/ # Landing publique (déjà déployée)
├── docs/ # Documentation
│ ├── produit.md
│ ├── marque.md
│ ├── decisions.md
│ └── tech/ # Doc technique
│ └── architecture.md # (ce fichier)
├── k3s/ # Manifests Kubernetes
├── Dockerfile.api # Build image api
├── Dockerfile.web # Build image web (nginx + bundle)
├── pnpm-workspace.yaml
├── package.json # Scripts root, devDependencies communes
├── tsconfig.base.json # Config TS partagée
└── CLAUDE.md
```
**Outils monorepo** :
- **pnpm workspaces** — léger, rapide, gestion native des liens symboliques entre packages
- **TypeScript project references** — résout les imports cross-package sans build préalable
- **Turborepo** *(optionnel, à voir au volume)* — cache + parallélisation des scripts
**Commandes racine** typiques :
```bash
pnpm install # installe tout
pnpm -F api dev # dev API
pnpm -F web dev # dev SPA
pnpm -F api migration:run # migrations DB
pnpm -F api test # tests API
pnpm build # build api + web pour prod
```
---
## 3. apps/api — AdonisJS v7
### Stack interne
- **AdonisJS v7** + **Lucid ORM** (PG)
- **`@adonisjs/auth`** — access tokens Bearer (stateless)
- **`@adonisjs/bouncer`** — autorisations par policy (admin/lecture/édition pour V2 multi-users)
- **`@adonisjs/mail`** — emails outbound (provider à choisir ADR-021)
- **`@adonisjs/queue`** ou **BullMQ** — jobs différés (relances programmées, OCR, check-ins)
- **`@adonisjs/limiter`** — rate limiting sur les routes publiques (login, signup)
- **Vine** (validateur natif Adonis 7) ou **Zod** côté API pour validation des payloads
### Conventions de routes
Toutes les routes API sous `/api/v1/`. Versioning explicite — V2 vivra côté `/api/v2/` sans casser V1.
```
POST /api/v1/auth/register
POST /api/v1/auth/login
POST /api/v1/auth/logout
POST /api/v1/auth/refresh
GET /api/v1/me
PATCH /api/v1/me
GET /api/v1/organizations/:id
GET /api/v1/invoices
POST /api/v1/invoices # create manual
POST /api/v1/invoices/upload # OCR pipeline
GET /api/v1/invoices/:id
PATCH /api/v1/invoices/:id
DELETE /api/v1/invoices/:id
POST /api/v1/invoices/:id/relance # relance manuelle
POST /api/v1/invoices/:id/mark-paid
GET /api/v1/plans
POST /api/v1/plans
PATCH /api/v1/plans/:id
DELETE /api/v1/plans/:id
GET /api/v1/clients
POST /api/v1/clients
PATCH /api/v1/clients/:id
GET /api/v1/dashboard/kpis
GET /api/v1/dashboard/activity
```
### Conventions de réponse
- JSON systématique
- Format succès : `{ data: ..., meta?: { ... } }`
- Format erreur : `{ errors: [{ code, message, field? }] }`
- Codes HTTP standards (200, 201, 204, 400, 401, 403, 404, 422, 500)
- Pagination cursor-based pour les listes (préférable à offset pour les flux modifiés en temps réel)
---
## 4. apps/web — React + Vite
### Stack interne
- **React 19** + **Vite 6**
- **TanStack Router** — routing file-based (à privilégier), search params type-safe pour les filtres facture
- **TanStack Query** — cache + invalidation + optimistic updates pour le state serveur
- **TailwindCSS** *(à confirmer)* — utility-first, cohérent avec les couleurs de marque
- **Lucide React** pour les icônes
- **Bricolage Grotesque + Inter** via Google Fonts (cohérent landing)
### Auth côté SPA (cf. ADR-017)
- **Access token** stocké en mémoire (variable de module / state Query) — pas localStorage pour éviter XSS
- **Refresh token** en cookie httpOnly + SameSite=Strict
- Au boot du SPA : appel `/auth/refresh` pour obtenir un nouvel access token (silent reauth)
- Si refresh échoue → redirect `/login`
### Organisation des routes
File-based via TanStack Router :
```
src/routes/
├── __root.tsx # Layout global + AuthGate
├── login.tsx
├── signup.tsx
├── _app/ # Routes protégées
│ ├── _app.tsx # Layout app (sidebar, header, brand)
│ ├── index.tsx # Dashboard
│ ├── factures.tsx # Liste factures
│ ├── factures.$id.tsx # Détail facture
│ ├── plans.tsx # Bibliothèque plans
│ ├── plans.$id.tsx # Éditeur plan
│ ├── clients.tsx
│ └── parametres.tsx
└── onboarding/
├── compte.tsx
├── entreprise.tsx
└── signature.tsx
```
---
## 5. packages/shared — types et schémas partagés
Le but : un client API typé fortement, sans duplication de définitions.
```ts
// packages/shared/src/types/invoice.ts
export type InvoiceStatus = 'pending' | 'awaiting_user_confirmation' | 'paid' | 'in_relance' | 'litigation' | 'cancelled'
export type Invoice = {
id: string
numero: string
clientId: string
amountTtc: number
dueDate: string // ISO
status: InvoiceStatus
planId: string | null
// ...
}
```
```ts
// packages/shared/src/schemas/invoice.ts
import { z } from 'zod'
export const createInvoiceSchema = z.object({
numero: z.string().min(1),
clientId: z.string().uuid(),
amountTtc: z.number().positive(),
dueDate: z.string().datetime(),
// ...
})
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>
```
**Avantage** : Adonis valide avec ce schéma, le SPA valide avec le même, le type est inféré une seule fois.
---
## 6. Flux de données critiques
### 6.1 Upload + OCR + création facture
```
SPA (drag & drop PDF)
│ POST /api/v1/invoices/upload (multipart)
api: stocke le PDF dans MinIO (bucket rubis-invoices)
│ retourne { uploadId, status: 'processing' }
api: enqueue job ProcessOcr(uploadId)
worker: récupère le PDF depuis MinIO
│ appel HTTP vers OCR provider
worker: parse les champs extraits, crée l'Invoice en DB (status: pending)
SPA: poll ou WebSocket → reçoit l'Invoice prête à valider
```
**Points d'attention** : le job OCR doit être idempotent (même uploadId rejoué = pas de duplicate). Le SPA peut afficher un spinner pendant les 3-10 secondes d'OCR.
### 6.2 Programmation des relances
```
SPA: utilisateur clique "Valider" sur l'Invoice
│ PATCH /api/v1/invoices/:id (status: scheduled, planId: …)
api: créé N RelanceTasks (une par étape du plan)
chaque RelanceTask a un sendAt (calculé d'après dueDate + offset étape)
queue: tâches en attente
│ ┌─ avant chaque relance, créer aussi un CheckinTask (T-2j) ─┐
▼ ▼ ▼
worker @ sendAt: vérifie l'état de l'Invoice (toujours pending ?)
│ si invoice.status === 'pending' → envoie l'email
│ sinon → no-op (l'invoice a été marquée payée entre-temps)
```
### 6.3 Check-in email à l'utilisateur
```
worker @ checkinTask.sendAt:
│ génère un token signé (avec invoice.id + reply_action: 'paid' | 'not_paid')
api: envoie un email à l'utilisateur (pas au client) avec 2 boutons
│ chaque bouton = lien GET /api/v1/checkin/:token (action embeddée)
utilisateur clique "Oui, j'ai été payé"
api: GET /checkin/:token → vérifie token, marque invoice.status = 'paid'
→ annule les RelanceTasks futures de cette invoice
→ redirect SPA avec confirmation
```
**Sécurité** : le token doit être signé (HMAC ou JWT court) et avoir une durée limitée (24h après émission). Pas d'auth Bearer requise pour ce endpoint car c'est un click depuis email.
### 6.4 Authentification Bearer
```
SPA: POST /api/v1/auth/login { email, password }
│ api valide credentials, crée AccessToken (TTL 30 min) + RefreshToken (TTL 30j httpOnly cookie)
SPA reçoit { accessToken, user } — accessToken stocké en mémoire
│ chaque requête API : Authorization: Bearer <accessToken>
30 min plus tard : 401 sur appel API
SPA: POST /api/v1/auth/refresh (cookie httpOnly envoyé auto)
│ api valide refresh, émet nouvel accessToken
SPA retry l'appel original avec nouveau token
```
---
## 7. Topologie de déploiement
### Réseau Proxmox
| Resource | Type | Rôle |
|---|---|---|
| Cluster K3s | Pool VMs Proxmox | Orchestration des pods app |
| LXC `postgres` | LXC dédié | PostgreSQL — accessible aux pods K3s via réseau interne |
| LXC `minio` | LXC dédié | MinIO — accessible aux pods K3s via réseau interne |
| Traefik | Reverse proxy | TLS termination + routing par hostname |
### Pods K3s
```yaml
# Namespace: rubis
- Deployment: rubis-api # AdonisJS Node, port 3333
- Deployment: rubis-web # nginx, sert le bundle Vite, port 80
- Deployment: rubis-landing # déjà existant
- Service: rubis-api-svc # ClusterIP
- Service: rubis-web-svc # ClusterIP
- Service: postgres-external # ExternalName → IP du LXC postgres
- Service: minio-external # ExternalName → IP du LXC minio
- Secret: rubis-config # DB credentials, MinIO credentials, OCR API key, mail API key
- IngressRoute (Traefik) :
api.rubis-sur-l-ongle.fr → rubis-api-svc:3333
app.rubis-sur-l-ongle.fr → rubis-web-svc:80
rubis-sur-l-ongle.fr → rubis-landing-svc:80
```
### Pipeline CI Gitea
```
git push gitea main
.gitea/workflows/build.yml
build & push images :
- git.arthurbarre.fr/ordinarthur/rubis-api:<sha>
- git.arthurbarre.fr/ordinarthur/rubis-web:<sha>
kubectl rollout (api + web)
healthchecks readinessProbe → service public
```
---
## 8. Conventions de code
| Domaine | Convention |
|---|---|
| Branches | `feat/<short-desc>`, `fix/<short-desc>`, `chore/<…>` |
| Commits | [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`) |
| TypeScript | `strict: true`, pas de `any` (sauf justifié + commenté), `noUncheckedIndexedAccess: true` |
| Linting | ESLint + Prettier, config partagée via `tsconfig.base.json` + `.eslintrc.cjs` racine |
| Tests | Japa (Adonis) pour API, Vitest pour shared/, Playwright pour E2E utilisateur (V2) |
| Migrations | Versionnées, jamais éditées rétroactivement (cf. principe ADR du log de décisions) |
| Secrets | Jamais en clair dans le repo. `.env.local` git-ignoré, secrets K3s pour la prod |
---
## 9. Sécurité & RGPD
- **Hébergement français** (Proxmox France) — conforme RGPD pour la cible TPE-PME
- **Chiffrement at-rest** : disque Proxmox chiffré (LUKS) — à confirmer côté infra
- **Chiffrement in-transit** : TLS partout (Traefik), connexions PG et MinIO en SSL interne
- **Rate limiting** : `@adonisjs/limiter` sur `/auth/*` (5 req/min par IP), routes OCR (10/h par utilisateur)
- **CORS** : whitelist stricte (`app.rubis-sur-l-ongle.fr` uniquement) — refus des origines tierces
- **CSRF** : non-applicable car auth via Bearer header (pas cookie session). Les endpoints email check-in utilisent des tokens signés à TTL court.
- **Backups** :
- PG : dump quotidien dans MinIO (`rubis-backups/pg/<date>.dump`)
- MinIO : snapshot Proxmox du LXC quotidien
- Retention : 30 jours mini (à confirmer)
- **Suppression** : RGPD Article 17 — endpoint `DELETE /api/v1/me` qui purge data utilisateur + factures + pièces jointes
---
## 10. Décisions encore en attente
À trancher avant fin V1, par ordre de priorité :
| # | Sujet | Échéance suggérée |
|---|---|---|
| 019 | **Domain model** (entités, relations, index) | Avant la 1ère migration |
| 020 | **Provider OCR** (Mindee, Document AI, Textract, Tesseract) | Avant l'implémentation du job ProcessOcr |
| 021 | **Provider email** (Resend, Postmark, SendGrid, AWS SES) | Avant l'implémentation des relances |
| 022 | **Pricing exact** (Free 5 factures ? Pro 19 €/mois ?) | Avant le payment flow |
| 023 | **Endpoint waitlist** (Resend / Formspree / API Adonis) | Au push de la landing en prod |
---
## 11. Évolutions V2+ anticipées
- **Multi-utilisateurs** : tables `organizations` et `memberships` à prévoir dès la V1 (même si UI mono-user)
- **SMS** : provider Twilio/OVH abstrait derrière un service `MessageDispatcher` qui route email/sms selon plan + cadence
- **Intégration banking** : webhook entrant sur `/api/v1/banking/payment-confirmed` qui marque les invoices payées automatiquement (le check-in email V1 devient fallback)
- **Intégrations comptables** (Pennylane/Sage) : modèle d'événement abstrait `invoice.created` exportable en webhook sortant
- **API publique** : sous `/api/v1/public/*` avec abilities/scopes par token (lecture seule, écriture limitée)
---
*Maintenu par Arthur + Claude. Ce document est versionné — les changements significatifs passent par un ADR dans `/docs/decisions.md`.*

763
docs/tech/frontend.md Normal file
View File

@ -0,0 +1,763 @@
# Guide d'implémentation — Frontend
> Version : 0.1 · Dernière maj : 2026-05-05
> Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-017 (auth).
Ce document est le **guide pratique d'implémentation du SPA**. Il complète `architecture.md` (qui décrit le **quoi**) en expliquant le **comment** : commandes exactes, snippets de config, conventions de dossier.
**À lire avant** :
- `/CLAUDE.md` — contexte top-level
- `/docs/produit.md` — flows utilisateur, IN/OUT V1
- `/docs/marque.md` — palette, typo, voix, do/don't
- `/docs/wireframes-mvp.html` — les 13 écrans MVP avec annotations
- `/docs/tech/architecture.md` — vue d'ensemble du système
---
## 1. Vue d'ensemble
L'app web (`apps/web/`) est un SPA React 19 buildé par Vite, qui consomme l'API AdonisJS `apps/api/` via un client HTTP type-safe (Tuyau). Le routing client est géré par **TanStack Router** (file-based, type-safe), le state serveur par **TanStack Query**, le styling par **Tailwind CSS v4** avec les tokens de marque issus de `marque.md`.
**Périmètre V1** : 13 écrans listés dans `wireframes-mvp.html`. Auth Bearer (cf. ADR-017) avec refresh token httpOnly cookie. Mobile responsive, pas d'app native.
**Hors scope V1** : SSR (pas nécessaire pour un SaaS B2B authentifié), i18n (FR uniquement), PWA offline (nice-to-have V2).
---
## 2. Dépendances
### Bootstrap du workspace
À exécuter à la racine du monorepo (après avoir créé `pnpm-workspace.yaml`) :
```bash
mkdir -p apps/web && cd apps/web
pnpm create vite@latest . --template react-ts
```
Choix Vite : `react-ts` (TypeScript natif).
### Dépendances runtime
```bash
# Routing & state serveur
pnpm add @tanstack/react-router @tanstack/react-query @tanstack/react-query-devtools
# Tooling Vite pour TanStack Router (file-based)
pnpm add -D @tanstack/router-plugin
# Client HTTP typé pour AdonisJS
pnpm add @tuyau/client
# UI primitives & icônes
pnpm add lucide-react
pnpm add clsx tailwind-merge
# Validation côté client (réutilise schemas Zod de packages/shared)
pnpm add zod
# Notifications/toasts
pnpm add sonner
# Dates (formatage français)
pnpm add date-fns
```
### Dépendances dev
```bash
# Tailwind v4
pnpm add -D tailwindcss @tailwindcss/vite
# TS strict + lint
pnpm add -D typescript@latest @types/react @types/react-dom
pnpm add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser
pnpm add -D eslint-plugin-react-hooks eslint-plugin-react-refresh
# Tests
pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom
# Type-checking strict
pnpm add -D tsc-files
```
### Référence au package shared
Dans `apps/web/package.json` :
```json
{
"dependencies": {
"@rubis/shared": "workspace:*"
}
}
```
Permet d'importer les types et schemas Zod depuis `packages/shared/` sans publication npm.
---
## 3. Tailwind CSS v4 + tokens de marque
Tailwind v4 utilise une configuration CSS-first (plus de `tailwind.config.js` requis). Les tokens de marque issus de `marque.md` deviennent des CSS variables.
### Installation
`vite.config.ts` :
```ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite({ routesDirectory: 'src/routes' }),
react(),
tailwindcss(),
],
})
```
### Tokens de marque dans `src/styles/app.css`
```css
@import "tailwindcss";
@theme {
/* Couleurs rubis */
--color-rubis: #9F1239;
--color-rubis-deep: #771328;
--color-rubis-light: #C9415C;
--color-rubis-glow: #FBE4EA;
/* Neutres chauds */
--color-cream: #FAF7F2;
--color-cream-2: #F5EFE7;
--color-line: #E8E0D6;
--color-ink: #1A1410;
--color-ink-2: #4F4640;
--color-ink-3: #8A7F76;
/* Typographies */
--font-display: "Bricolage Grotesque", -apple-system, BlinkMacSystemFont, sans-serif;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
}
/* Globals */
body {
font-family: var(--font-sans);
background: var(--color-cream);
color: var(--color-ink);
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
}
/* Selection */
::selection {
background: var(--color-rubis);
color: white;
}
```
Importé dans `main.tsx` : `import './styles/app.css'`.
### Polices Google Fonts
`index.html` :
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
```
### Usage typique
```tsx
<button className="bg-rubis text-white hover:bg-rubis-deep px-4 py-2 rounded-md font-medium">
Démarrer
</button>
<h1 className="font-display text-4xl tracking-tight">
Bonjour Arthur
</h1>
```
### Règles de marque appliquées
- **Pas de `bg-white` en pleine page** → toujours `bg-cream` (#FAF7F2)
- **Pas de `bg-black` ni `text-black`** → utiliser `bg-ink` et `text-ink` (#1A1410)
- **Le rubis est rare** → un seul aplat fort par écran maximum
- **Italique rubis** sur le mot-clé d'un titre : `<em className="italic text-rubis">`
- **Le ◆** est un SVG custom, jamais une icône Lucide (cf. `marque.md`)
---
## 4. TanStack Router — routing file-based
### Pourquoi file-based
Le routing file-based est **type-safe nativement** (les params, search params, et loaders sont inférés depuis les fichiers), il évite la déclaration manuelle d'un router central, et il s'aligne avec la structure d'écrans du wireframe.
### Structure des routes
Référence : les 13 écrans dans `wireframes-mvp.html`.
```
apps/web/src/routes/
├── __root.tsx # Layout global, providers, AuthGate
├── login.tsx # 1.2 Connexion
├── signup.tsx # 1.1 Inscription
├── _onboarding/ # Layout onboarding (sans sidebar)
│ ├── _onboarding.tsx
│ ├── compte.tsx # 1.3 step 1
│ ├── entreprise.tsx # 1.3 step 2
│ └── signature.tsx # 1.3 step 3
└── _app/ # Layout app authentifiée
├── _app.tsx # Layout : sidebar + topbar + tab bar mobile
├── index.tsx # 4.1 Dashboard
├── factures.tsx # 2.4 Liste filtrable
├── factures.$id.tsx # 4.2 Détail facture (timeline)
├── factures.import.$batchId.tsx # 2.2 Vérification OCR
├── plans.tsx # 3.1 Bibliothèque
├── plans.$slug.tsx # 3.2 Éditeur (cadence + templates)
├── clients.tsx # liste clients
└── parametres.tsx # paramètres compte
```
Les routes commençant par `_` sont des **layout routes** (n'ajoutent pas de segment URL).
### Configuration root
`src/routes/__root.tsx` :
```tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { Toaster } from 'sonner'
export const Route = createRootRoute({
component: RootLayout,
notFoundComponent: NotFound,
})
function RootLayout() {
return (
<>
<Outlet />
<Toaster position="bottom-right" />
{import.meta.env.DEV && (
<>
<TanStackRouterDevtools />
<ReactQueryDevtools />
</>
)}
</>
)
}
function NotFound() {
return <div className="p-8">Page introuvable.</div>
}
```
### Auth guard sur le layout `_app`
`src/routes/_app/_app.tsx` :
```tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { AppLayout } from '@/components/layout/AppLayout'
import { authStore } from '@/lib/auth'
export const Route = createFileRoute('/_app')({
beforeLoad: async ({ location }) => {
if (!authStore.isAuthenticated()) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
component: AppLayoutComponent,
})
function AppLayoutComponent() {
return (
<AppLayout>
<Outlet />
</AppLayout>
)
}
```
### Search params type-safe (filtres factures)
`src/routes/_app/factures.tsx` :
```tsx
import { z } from 'zod'
import { createFileRoute } from '@tanstack/react-router'
const filterSchema = z.object({
statut: z.enum(['toutes', 'a_relancer', 'en_relance', 'encaissees', 'litige']).optional(),
q: z.string().optional(),
page: z.number().int().min(1).optional().default(1),
})
export const Route = createFileRoute('/_app/factures')({
validateSearch: filterSchema,
component: FacturesPage,
})
```
→ Les filtres sont dans l'URL, partageables par lien, persistés au reload, type-safe à l'usage : `Route.useSearch()` retourne le bon type.
---
## 5. TanStack Query — state serveur
### Provider racine
`src/main.tsx` :
```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s par défaut, ajusté par query
gcTime: 5 * 60_000, // 5 min en cache
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
})
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
)
```
### Convention queryKeys
Dans `src/lib/queryKeys.ts` :
```ts
export const queryKeys = {
me: () => ['me'] as const,
invoices: {
all: () => ['invoices'] as const,
list: (filters: InvoiceFilters) => ['invoices', 'list', filters] as const,
detail: (id: string) => ['invoices', 'detail', id] as const,
},
plans: {
all: () => ['plans'] as const,
detail: (slug: string) => ['plans', 'detail', slug] as const,
},
clients: {
all: () => ['clients'] as const,
},
dashboard: {
kpis: () => ['dashboard', 'kpis'] as const,
activity: () => ['dashboard', 'activity'] as const,
},
} as const
```
→ Permet d'invalider précisément après une mutation : `queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() })`.
### Patterns d'invalidation après mutation
```ts
const markPaidMutation = useMutation({
mutationFn: (id: string) => api.invoices({ id }).markPaid.$post(),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() })
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.kpis() })
toast.success('Facture marquée encaissée. + 1 rubis.')
},
})
```
---
## 6. Tuyau — client HTTP typé pour AdonisJS
[Tuyau](https://github.com/Julien-R44/tuyau) est l'équivalent tRPC pour AdonisJS. Il génère un client TS qui connaît toutes les routes API, leurs payloads, et leurs réponses — depuis le code Adonis lui-même.
### Côté API (à faire dans `apps/api/`)
```bash
cd apps/api
pnpm add @tuyau/core
node ace add @tuyau/core
```
Configuration : annoter les routes Adonis avec un nom (utilisé pour l'autocomplete TS) :
```ts
// apps/api/start/routes.ts
router.group(() => {
router.post('/auth/login', '#controllers/auth_controller.login').as('auth.login')
router.get('/me', '#controllers/me_controller.show').as('me.show').use(middleware.auth())
router.get('/invoices', '#controllers/invoices_controller.index')
.as('invoices.index').use(middleware.auth())
router.post('/invoices', '#controllers/invoices_controller.store')
.as('invoices.store').use(middleware.auth())
router.get('/invoices/:id', '#controllers/invoices_controller.show')
.as('invoices.show').use(middleware.auth())
// ...
}).prefix('/api/v1')
```
Génération du client typé :
```bash
node ace tuyau:generate
```
Crée `.adonisjs/api.ts` qui décrit toutes les routes en types TS. Ce fichier est versionné OU regénéré au build (à choisir).
### Côté SPA (`apps/web/`)
```bash
cd apps/web
pnpm add @tuyau/client
```
`src/lib/api.ts` :
```ts
import { createTuyau } from '@tuyau/client'
import { api } from '../../../api/.adonisjs/api' // import des types depuis l'API
export const tuyau = createTuyau({
api,
baseUrl: import.meta.env.VITE_API_URL, // ex. https://api.rubis-sur-l-ongle.fr
credentials: 'include', // envoie les cookies (refresh token)
headers: () => ({
Authorization: authStore.token ? `Bearer ${authStore.token}` : '',
}),
})
```
### Intégration avec TanStack Query
```tsx
import { useQuery } from '@tanstack/react-query'
import { tuyau } from '@/lib/api'
import { queryKeys } from '@/lib/queryKeys'
export function useInvoices(filters: InvoiceFilters) {
return useQuery({
queryKey: queryKeys.invoices.list(filters),
queryFn: async () => {
const { data, error } = await tuyau.api.v1.invoices.$get({ query: filters })
if (error) throw error
return data // typé depuis le controller Adonis
},
})
}
```
**Zéro DTO manuel**, **autocomplete partout**, **erreur de compilation si l'API change** sans MAJ du SPA. Le contrat API ↔ web est verrouillé par TS.
### Génération automatique au dev
Ajouter un script `apps/api/package.json` :
```json
{
"scripts": {
"tuyau:watch": "node ace tuyau:generate --watch"
}
}
```
→ Pendant le dev, modifier une route API régénère les types instantanément côté SPA.
---
## 7. Auth Bearer + refresh token (cf. ADR-017)
### Auth store (en mémoire, pas localStorage)
`src/lib/auth.ts` :
```ts
type AuthState = {
token: string | null
user: User | null
}
class AuthStore {
private state: AuthState = { token: null, user: null }
private listeners = new Set<() => void>()
get token() { return this.state.token }
get user() { return this.state.user }
isAuthenticated() { return this.state.token !== null }
setSession(token: string, user: User) {
this.state = { token, user }
this.notify()
}
clear() {
this.state = { token: null, user: null }
this.notify()
}
subscribe(fn: () => void) {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
private notify() { this.listeners.forEach(fn => fn()) }
}
export const authStore = new AuthStore()
```
→ Le token vit en mémoire. Au refresh de la page, il est perdu mais récupérable via `/auth/refresh` (qui lit le cookie httpOnly).
### Bootstrap session au boot du SPA
`src/main.tsx` (avant le render) :
```ts
async function bootstrap() {
try {
const { data } = await tuyau.api.v1.auth.refresh.$post()
if (data) authStore.setSession(data.accessToken, data.user)
} catch {
// pas de refresh valide, rester anonyme
}
}
bootstrap().then(() => render())
```
→ Si l'utilisateur a déjà une session valide (cookie refresh non expiré), le SPA récupère un access token avant le 1er render. Pas d'écran flash de login.
### Auto-refresh sur 401
Intercepteur dans `tuyau` (à customiser) ou dans un wrapper `api()` qui retry sur 401 après un refresh silent.
---
## 8. Pages à construire
Référence visuelle : `/docs/wireframes-mvp.html`. Référence brand : `/docs/marque.md`.
| # | Route | Wireframe | Priorité | Notes |
|---|---|---|---|---|
| 1 | `/login` | 1.2 | P0 | Email + password + Google SSO. |
| 2 | `/signup` | 1.1 | P0 | 2 champs + Google SSO. |
| 3 | `/onboarding/compte` | 1.3 step 1 | P0 | Préfilled email après signup. |
| 4 | `/onboarding/entreprise` | 1.3 step 2 | P0 | Wizard avec chips volume mensuel. |
| 5 | `/onboarding/signature` | 1.3 step 3 | P0 | Signature email pour les relances. |
| 6 | `/_app/` (Dashboard) | 4.1 | P0 | Hero rubis, KPIs, activité du jour. |
| 7 | `/_app/factures` | 2.4 | P0 | Liste + chips de filtre + actions en lot. |
| 8 | `/_app/factures` (empty) | 2.1 | P0 | Dropzone + drag&drop. |
| 9 | `/_app/factures/import/$batchId` | 2.2 | P0 | Split PDF / formulaire OCR. |
| 10 | (modal) | 2.3 | P1 | Saisie manuelle. |
| 11 | `/_app/factures/$id` | 4.2 | P0 | Timeline + sidepanel client + notes. |
| 12 | `/_app/plans` | 3.1 | P0 | Cards 4 plans pré-fournis. |
| 13 | `/_app/plans/$slug` | 3.2 | P0 | Éditeur cadence + email avec variables chips. |
| 14 | (storyboard 3 clics) | 3.3 | — | Concept landing, pas un écran applicatif. |
| 15 | Mobile dashboard | 4.3 | P0 | Responsive, pas une route séparée. |
**Composants UI partagés à factoriser tôt** :
- `<Brand>` — gem ◆ + wordmark "Rubis" / "Rubis Sur l'Ongle"
- `<Button variant="primary"|"secondary"|"ghost">` — cohérent avec la landing
- `<Input>`, `<Textarea>`, `<Select>` — avec label + erreur
- `<Card>`, `<Panel>` — surfaces standardisées
- `<Modal>` — pour mise en demeure (validation manuelle obligatoire), saisie manuelle
- `<Chip>` — filtres et tags de volume/tonalité
- `<RubisCounter>` — composant héros gamification
- `<Stepper>` — wizard onboarding 3 étapes
- `<Timeline>` — pour le détail facture
- `<Dropzone>` — drag & drop multi-fichiers PDF
---
## 9. Conventions
### Structure de dossiers
```
apps/web/src/
├── routes/ # TanStack Router file-based
├── components/
│ ├── ui/ # Primitives (Button, Input, Card…)
│ ├── layout/ # AppLayout, OnboardingLayout
│ ├── factures/ # Composants spécifiques au domaine factures
│ ├── plans/ # Composants spécifiques aux plans
│ └── shared/ # Brand, RubisCounter, Stepper…
├── lib/
│ ├── api.ts # Client Tuyau
│ ├── auth.ts # Auth store
│ ├── queryKeys.ts # Convention queryKeys
│ ├── format.ts # Formateurs (€, dates, durées en rubis…)
│ └── utils.ts # cn() helper, divers
├── hooks/
│ ├── useInvoices.ts # Wrapper TanStack Query par domaine
│ ├── usePlans.ts
│ └── …
├── styles/
│ └── app.css # Imports Tailwind + tokens
└── main.tsx # Bootstrap + providers
```
### Naming
- Composants : `PascalCase.tsx`
- Hooks : `useCamelCase.ts`
- Helpers : `camelCase.ts`
- Routes : `kebab-case.tsx` (TanStack Router file-based)
- Tests : `*.test.tsx` colocalisé avec le composant
### Style code
- TypeScript `strict: true`, pas de `any` non justifié
- Imports absolus via alias `@/*` mappé sur `src/*` (`tsconfig.json` + `vite.config.ts`)
- Préférer destructuring + early returns plutôt qu'imbriquer
- Composants fonctionnels uniquement, hooks pour la logique
- Une responsabilité par composant — refactor en plusieurs fichiers dès qu'un composant dépasse ~200 lignes
### Formateurs métier
Centraliser dans `src/lib/format.ts` les conversions récurrentes :
```ts
export const formatEuros = (cents: number) => /* "1 240,00 €" */
export const formatRubisToHours = (rubis: number) => /* "20 h 40" */
export const formatDate = (iso: string) => /* "5 mai 2026" */
export const formatRelativeDate = (iso: string) => /* "dans 3 jours" */
```
→ Cohérence de l'affichage partout, et on évite que chaque composant fasse son `Intl.NumberFormat` à la main.
---
## 10. Variables d'environnement
`apps/web/.env.local` (git-ignoré) :
```bash
VITE_API_URL=http://localhost:3333
VITE_PUBLIC_LANDING_URL=https://rubis-sur-l-ongle.fr
```
Production via secret K3s injecté dans le build Vite :
```bash
VITE_API_URL=https://api.rubis-sur-l-ongle.fr
VITE_PUBLIC_LANDING_URL=https://rubis-sur-l-ongle.fr
```
Toutes les vars accessibles côté SPA **doivent être préfixées `VITE_`** (sinon Vite ne les expose pas au bundle).
---
## 11. Build & déploiement
### Build local
```bash
pnpm -F web build # produit apps/web/dist/
```
### Image Docker
`Dockerfile.web` à la racine du monorepo :
```dockerfile
# Stage 1 : build
FROM node:22-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/web ./apps/web
COPY packages/shared ./packages/shared
RUN pnpm install --frozen-lockfile
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm -F web build
# Stage 2 : nginx
FROM nginx:1.27-alpine
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
# Réutilise le même nginx.conf que la landing (try_files / SPA)
COPY infra/nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
Ce Dockerfile sera buildé en parallèle du `Dockerfile.api` par le CI Gitea, puis poussé en `git.arthurbarre.fr/ordinarthur/rubis-web:<sha>`.
---
## 12. Pointeurs vers l'existant
Quand tu reconstruis l'app, **rien ne se réinvente sans consulter** :
- **Wireframes** : `/docs/wireframes-mvp.html` — 13 écrans avec annotations UX par écran
- **Brand visuel** : `/docs/brand-identity.html` — logo direction A, palette en application
- **Brand écrit** : `/docs/marque.md` — règles do/don't, voix
- **Voix & microcopy** : `/docs/marque.md` section 7 — messages de notif, empty states, erreurs
- **Landing déployée** : `/landing/index.html` — référence vivante de couleurs/spacing/typo en HTML/Tailwind-compatible
→ Avant de styler un composant, **regarde la landing déployée** ou le wireframe correspondant. Cohérence visuelle = signature de marque tenue.
---
## 13. Points d'attention
- **Auth Bearer en mémoire** : si l'utilisateur reload, l'access token est perdu — toujours appeler `/auth/refresh` au boot avant le render initial (cf. section 7)
- **Tuyau import path** : le client SPA importe les types depuis `apps/api/.adonisjs/api.ts` — bien configurer les `paths` du `tsconfig.json` pour que ça fonctionne en monorepo
- **Polices** : Bricolage Grotesque doit être préchargée (preconnect Google Fonts) sinon FOUT/FOIT visible sur les titres
- **Le ◆** : c'est un SVG inline, pas une icône Lucide. Composant `<Gem />` à coder à part — réutilisé partout (sidebar, dashboard hero, badge mobile, CTA gamification)
- **Mobile-first sur les actions critiques** : la photo de facture depuis le tel est un usage clé (cf. wireframe 4.3) — ne pas la traiter comme une feature secondaire
- **3 clics maximum** : règle de design (cf. ADR-011) — chaque parcours doit être contesté à l'aune de cette règle
---
## 14. Décisions encore à prendre côté frontend
| Sujet | Quand trancher |
|---|---|
| Lib de formulaires (TanStack Form vs react-hook-form vs natif) | Avant le 1er formulaire complexe (signup ou plan editor) |
| State client local (Zustand vs context vs Jotai) | Probablement pas nécessaire en V1 — TanStack Query gère 95 % du state |
| Composants accessibility primitives (Radix vs Headless UI vs natif) | Avant le 1er Modal/Select complexe |
| Tests E2E (Playwright vs Cypress) | Phase polish |
---
*Maintenu par Arthur + Claude. Les décisions structurelles passent par un ADR dans `/docs/decisions.md`.*

58
eslint.config.js Normal file
View File

@ -0,0 +1,58 @@
// @ts-check
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
export default tseslint.config(
{
ignores: [
"**/dist/**",
"**/build/**",
"**/.turbo/**",
"**/node_modules/**",
"**/.adonisjs/**",
"**/routeTree.gen.ts",
"**/coverage/**",
"landing/**",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
},
},
{
files: ["apps/web/**/*.{ts,tsx}"],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "rubis",
"version": "0.1.0",
"private": true,
"description": "Rubis Sur l'Ongle — SaaS de relance de factures impayées pour TPE-PME françaises",
"packageManager": "pnpm@10.0.0",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "turbo run dev --parallel",
"dev:web": "turbo run dev --filter=@rubis/web",
"dev:api": "turbo run dev --filter=@rubis/api",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,css}\" --ignore-path .prettierignore",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,css}\" --ignore-path .prettierignore",
"prepare": "husky || true"
},
"devDependencies": {
"@types/node": "^22.10.0",
"eslint": "^9.18.0",
"husky": "^9.1.7",
"lint-staged": "^15.3.0",
"prettier": "^3.4.2",
"turbo": "^2.3.3",
"typescript": "^5.7.3"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "msw", "better-sqlite3"]
}
}

View File

@ -0,0 +1,35 @@
{
"name": "@rubis/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Types, Zod schemas et constantes partagés api ↔ web",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./types/*": {
"types": "./src/types/*.ts",
"default": "./src/types/*.ts"
},
"./schemas/*": {
"types": "./src/schemas/*.ts",
"default": "./src/schemas/*.ts"
},
"./constants": {
"types": "./src/constants/index.ts",
"default": "./src/constants/index.ts"
}
},
"scripts": {
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View File

@ -0,0 +1,46 @@
/**
* Constantes métier partagées entre l'API et le SPA.
* Une seule source de vérité pas de duplication.
*/
/** 1 rubis = 10 minutes libérées (cf. CLAUDE.md, glossaire) */
export const MINUTES_PER_RUBIS = 10;
/** Statuts possibles d'une facture (cf. architecture.md §5) */
export const INVOICE_STATUSES = [
"pending",
"awaiting_user_confirmation",
"in_relance",
"paid",
"litigation",
"cancelled",
] as const;
/** Tonalité d'un email de relance — du plus doux au plus ferme. */
export const RELANCE_TONES = ["amical", "courtois", "ferme", "mise_en_demeure"] as const;
/** Plans pré-fournis par défaut (cf. produit.md) */
export const DEFAULT_PLAN_SLUGS = [
"standard-30j",
"rapide-15j",
"patient-60j",
"ferme-7j",
] as const;
/** Volumes mensuels de facturation pour l'onboarding (chips) */
export const MONTHLY_VOLUME_BUCKETS = [
"moins-10",
"10-50",
"50-100",
"100-200",
"plus-200",
] as const;
/** Formats de fichier acceptés à l'import */
export const ACCEPTED_INVOICE_MIME_TYPES = [
"application/pdf",
"image/png",
"image/jpeg",
] as const;
export const MAX_INVOICE_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 Mo

View File

@ -0,0 +1,15 @@
// Types
export * from "./types/auth.js";
export * from "./types/user.js";
export * from "./types/client.js";
export * from "./types/invoice.js";
export * from "./types/plan.js";
// Schemas
export * from "./schemas/auth.js";
export * from "./schemas/client.js";
export * from "./schemas/invoice.js";
export * from "./schemas/plan.js";
// Constants
export * from "./constants/index.js";

View File

@ -0,0 +1,31 @@
import { z } from "zod";
/**
* Schemas de validation côté client ET côté API.
* Les messages d'erreur sont en français pour qu'ils puissent
* être affichés directement dans l'UI sans traduction.
*/
export const loginSchema = z.object({
email: z
.string({ required_error: "Votre email est requis" })
.email("Format d'email invalide"),
password: z
.string({ required_error: "Mot de passe requis" })
.min(1, "Mot de passe requis"),
});
export const registerSchema = z.object({
email: z
.string({ required_error: "Votre email est requis" })
.email("Format d'email invalide"),
password: z
.string({ required_error: "Mot de passe requis" })
.min(8, "Au moins 8 caractères, on est sérieux"),
fullName: z
.string({ required_error: "Votre prénom et nom" })
.min(2, "Au moins 2 caractères"),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;

View File

@ -0,0 +1,14 @@
import { z } from "zod";
export const createClientSchema = z.object({
name: z.string().min(1, "Le nom du client est requis").max(120),
email: z.string().email("Email invalide").nullable().optional(),
phone: z.string().max(40).nullable().optional(),
address: z.string().max(500).nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
});
export const updateClientSchema = createClientSchema.partial();
export type CreateClientInput = z.infer<typeof createClientSchema>;
export type UpdateClientInput = z.infer<typeof updateClientSchema>;

View File

@ -0,0 +1,34 @@
import { z } from "zod";
import { INVOICE_STATUSES } from "../constants/index.js";
export const invoiceStatusSchema = z.enum(INVOICE_STATUSES);
export const createInvoiceSchema = z.object({
clientId: z.string().uuid("Client invalide"),
numero: z
.string()
.min(1, "Numéro requis")
.max(50, "50 caractères maximum"),
amountTtcCents: z
.number({ invalid_type_error: "Montant invalide" })
.int("Montant en centimes (entier)")
.positive("Le montant doit être positif"),
issueDate: z.string().datetime({ message: "Date d'émission invalide" }),
dueDate: z.string().datetime({ message: "Date d'échéance invalide" }),
planId: z.string().uuid().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
});
export const updateInvoiceSchema = createInvoiceSchema.partial();
/** Schéma des filtres URL — utilisé par TanStack Router validateSearch. */
export const invoiceListFiltersSchema = z.object({
status: invoiceStatusSchema.or(z.literal("all")).optional(),
q: z.string().optional(),
clientId: z.string().uuid().optional(),
page: z.number().int().min(1).optional().default(1),
});
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
export type UpdateInvoiceInput = z.infer<typeof updateInvoiceSchema>;
export type InvoiceListFiltersInput = z.infer<typeof invoiceListFiltersSchema>;

View File

@ -0,0 +1,32 @@
import { z } from "zod";
import { RELANCE_TONES } from "../constants/index.js";
export const relanceToneSchema = z.enum(RELANCE_TONES);
export const planStepSchema = z.object({
order: z.number().int().min(0),
offsetDays: z
.number()
.int()
.min(-30, "Pas plus de 30j avant échéance")
.max(180, "Pas plus de 180j après échéance"),
tone: relanceToneSchema,
subject: z.string().min(1, "Sujet requis").max(200),
body: z.string().min(1, "Corps requis").max(5000),
requiresManualValidation: z.boolean(),
});
export const createPlanSchema = z.object({
name: z.string().min(1, "Nom du plan requis").max(80),
description: z.string().max(500).default(""),
steps: z
.array(planStepSchema)
.min(1, "Au moins une étape")
.max(10, "Pas plus de 10 étapes — on reste raisonnable"),
});
export const updatePlanSchema = createPlanSchema.partial();
export type CreatePlanInput = z.infer<typeof createPlanSchema>;
export type UpdatePlanInput = z.infer<typeof updatePlanSchema>;
export type PlanStepInput = z.infer<typeof planStepSchema>;

View File

@ -0,0 +1,16 @@
import type { User } from "./user.js";
/**
* Réponse au login / register / refresh.
* Le `accessToken` est stocké en mémoire côté SPA (cf. ADR-017).
* Le `refreshToken` est en cookie httpOnly et n'est jamais visible ici.
*
* Les inputs (`LoginInput`, `RegisterInput`) sont définis depuis les schemas Zod
* dans `schemas/auth.ts` (source unique de vérité pour valider et typer).
*/
export type AuthSession = {
accessToken: string;
/** ISO 8601, expiration du access token. */
expiresAt: string;
user: User;
};

Some files were not shown because too many files have changed in this diff Show More