Skill: /init-project
Scaffold un projet monorepo complet avec backend FastAPI + frontend React/Vite + DevOps.
Usage
/init-project <nom-du-projet>
Crée un dossier <nom-du-projet>/ dans le répertoire courant avec la structure complète.
Étape 1 — Scaffold Frontend
- •Créer le dossier
{projet}/frontend - •Exécuter dans
{projet}/frontend:bashnpm create vite@latest . -- --template react-ts npx shadcn@latest init --style default --base-color neutral --css-variables yes npx shadcn@latest add dashboard-01
- •Installer les dépendances:
bash
npm install @tanstack/react-router @tanstack/router-plugin @tanstack/react-query @tanstack/router-devtools @tanstack/react-query-devtools sonner
- •Le plugin Tailwind CSS v4 est déjà inclus via
@tailwindcss/vite(installé par Vite template)
Étape 2 — Personnaliser Frontend
2.1 — Polices Gilroy
Copier les polices depuis les assets du skill vers {projet}/frontend/public/fonts/:
~/.claude/skills/init-project/assets/fonts/Gilroy-Regular.ttf ~/.claude/skills/init-project/assets/fonts/Gilroy-Medium.ttf ~/.claude/skills/init-project/assets/fonts/Gilroy-SemiBold.ttf ~/.claude/skills/init-project/assets/fonts/Gilroy-Bold.ttf
2.2 — index.css
Remplacer src/index.css par le thème oklch + @font-face Gilroy. Utiliser exactement ce pattern:
@import "tailwindcss";
/* Gilroy font family */
@font-face {
font-family: "Gilroy";
src: url("/fonts/Gilroy-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Gilroy";
src: url("/fonts/Gilroy-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Gilroy";
src: url("/fonts/Gilroy-SemiBold.ttf") format("truetype");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Gilroy";
src: url("/fonts/Gilroy-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* CSS variables for theming (Shadcn compatible) */
@theme {
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
--color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.145 0 0);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.145 0 0);
--color-primary: oklch(0.205 0 0);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.97 0 0);
--color-secondary-foreground: oklch(0.205 0 0);
--color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.556 0 0);
--color-accent: oklch(0.97 0 0);
--color-accent-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.577 0.245 27.325);
--color-destructive-foreground: oklch(0.577 0.245 27.325);
--color-border: oklch(0.922 0 0);
--color-input: oklch(0.922 0 0);
--color-ring: oklch(0.708 0 0);
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
@theme {
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.145 0 0);
--color-card-foreground: oklch(0.985 0 0);
--color-popover: oklch(0.145 0 0);
--color-popover-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.985 0 0);
--color-primary-foreground: oklch(0.205 0 0);
--color-secondary: oklch(0.269 0 0);
--color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.269 0 0);
--color-muted-foreground: oklch(0.708 0 0);
--color-accent: oklch(0.269 0 0);
--color-accent-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.396 0.141 25.723);
--color-destructive-foreground: oklch(0.637 0.237 25.331);
--color-border: oklch(0.269 0 0);
--color-input: oklch(0.269 0 0);
--color-ring: oklch(0.439 0 0);
}
}
/* Base styles */
body {
@apply bg-background text-foreground;
font-family: "Gilroy", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
}
* {
@apply border-border;
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
2.3 — vite.config.ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
import path from "path"
export default defineConfig({
plugins: [TanStackRouterVite({}), react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
})
2.4 — main.tsx
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { RouterProvider, createRouter } from "@tanstack/react-router"
import { routeTree } from "./routeTree.gen"
import "./index.css"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
})
const router = createRouter({ routeTree })
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
)
2.5 — lib/api.ts
Créer le fetch wrapper JWT avec auto-refresh. Pattern exact:
const API_BASE = "/api"
// Token storage
const TOKEN_KEY = "access_token"
const REFRESH_TOKEN_KEY = "refresh_token"
export function getAccessToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
export function setTokens(accessToken: string, refreshToken: string): void {
localStorage.setItem(TOKEN_KEY, accessToken)
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
}
export function clearTokens(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
// Custom error class
export class ApiRequestError extends Error {
status: number
detail: string
constructor(status: number, detail: string) {
super(detail)
this.name = "ApiRequestError"
this.status = status
this.detail = detail
}
}
interface ValidationError {
loc?: string[]
msg?: string
}
interface ErrorBody {
detail: string | ValidationError[]
}
function formatErrorMessage(errorBody: ErrorBody): string {
if (Array.isArray(errorBody.detail)) {
return errorBody.detail
.map((err) => {
const field = err.loc?.slice(-1)[0] || "unknown"
return `${field}: ${err.msg || "Invalid value"}`
})
.join(", ")
}
return errorBody.detail || "An error occurred"
}
let refreshPromise: Promise<boolean> | null = null
async function refreshAccessToken(): Promise<boolean> {
const refreshToken = getRefreshToken()
if (!refreshToken) return false
try {
const response = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
})
if (!response.ok) {
clearTokens()
return false
}
const data = await response.json()
setTokens(data.access_token, data.refresh_token)
return true
} catch {
clearTokens()
return false
}
}
// Main fetch wrapper
async function apiFetch<T>(
endpoint: string,
options: RequestInit = {},
retry = true
): Promise<T> {
const accessToken = getAccessToken()
const headers: HeadersInit = {
"Content-Type": "application/json",
...options.headers,
}
if (accessToken) {
(headers as Record<string, string>).Authorization = `Bearer ${accessToken}`
}
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
})
// Handle 401 - try refresh
if (response.status === 401 && retry) {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null
})
}
const refreshed = await refreshPromise
if (refreshed) {
return apiFetch<T>(endpoint, options, false)
}
window.location.href = "/login"
throw new ApiRequestError(401, "Session expired")
}
if (!response.ok) {
const errorBody = await response.json().catch(() => ({
detail: "An error occurred",
}))
throw new ApiRequestError(response.status, formatErrorMessage(errorBody))
}
if (response.status === 204) {
return undefined as T
}
return response.json()
}
// HTTP method helpers
export const api = {
get<T>(endpoint: string): Promise<T> {
return apiFetch<T>(endpoint, { method: "GET" })
},
post<T>(endpoint: string, data?: unknown): Promise<T> {
return apiFetch<T>(endpoint, {
method: "POST",
body: data ? JSON.stringify(data) : undefined,
})
},
put<T>(endpoint: string, data?: unknown): Promise<T> {
return apiFetch<T>(endpoint, {
method: "PUT",
body: data ? JSON.stringify(data) : undefined,
})
},
patch<T>(endpoint: string, data?: unknown): Promise<T> {
return apiFetch<T>(endpoint, {
method: "PATCH",
body: data ? JSON.stringify(data) : undefined,
})
},
delete<T>(endpoint: string): Promise<T> {
return apiFetch<T>(endpoint, { method: "DELETE" })
},
}
// Auth API
export const authApi = {
login(email: string, password: string) {
return api.post<{
access_token: string
refresh_token: string
token_type: string
requires_totp: boolean
totp_pending_token?: string
}>("/auth/login", { email, password })
},
verifyTotp(code: string, totpPendingToken: string) {
return api.post<{
access_token: string
refresh_token: string
token_type: string
}>("/auth/verify-totp", { totp_pending_token: totpPendingToken, code })
},
getMe() {
return api.get<import("@/types").User>("/auth/me")
},
setupTotp() {
return api.post<{ secret: string; qr_code_base64: string; provisioning_uri: string }>(
"/auth/setup-totp"
)
},
enableTotp(code: string) {
return api.post<{ message: string }>("/auth/enable-totp", { code })
},
disableTotp(code: string) {
return api.post<{ message: string }>("/auth/disable-totp", { code })
},
}
Note: Ne PAS inclure les endpoints métier (adminApi, clientApi). Le projet scaffoldé ne contient que authApi. Les endpoints métier seront ajoutés au fur et à mesure du développement.
2.6 — hooks/useAuth.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { authApi, setTokens, clearTokens, getAccessToken } from "@/lib/api"
import type { User } from "@/types"
export function useAuth() {
const queryClient = useQueryClient()
const {
data: user,
isLoading,
error,
} = useQuery<User>({
queryKey: ["auth", "me"],
queryFn: authApi.getMe,
enabled: !!getAccessToken(),
retry: false,
staleTime: 5 * 60 * 1000,
})
const loginMutation = useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
authApi.login(email, password),
onSuccess: (data) => {
if (!data.requires_totp) {
setTokens(data.access_token!, data.refresh_token!)
queryClient.invalidateQueries({ queryKey: ["auth", "me"] })
}
},
})
const totpMutation = useMutation({
mutationFn: ({ code, tempToken }: { code: string; tempToken: string }) =>
authApi.verifyTotp(code, tempToken),
onSuccess: (data) => {
setTokens(data.access_token, data.refresh_token)
queryClient.invalidateQueries({ queryKey: ["auth", "me"] })
},
})
const logout = () => {
clearTokens()
queryClient.clear()
window.location.href = "/login"
}
return {
user,
isLoading,
isAuthenticated: !!user,
isAdmin: user?.is_admin ?? false,
error,
login: loginMutation.mutateAsync,
loginPending: loginMutation.isPending,
loginError: loginMutation.error,
verifyTotp: totpMutation.mutateAsync,
totpPending: totpMutation.isPending,
totpError: totpMutation.error,
logout,
}
}
2.7 — types/index.ts
export interface User {
id: string
email: string
first_name: string
last_name: string
company_id: string | null
company_name: string | null
is_admin: boolean
is_active: boolean
totp_enabled: boolean
created_at: string
last_login_at: string | null
}
2.8 — lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(amount: number, currency = "CHF"): string {
return new Intl.NumberFormat("fr-CH", {
style: "currency",
currency,
}).format(amount)
}
export function formatDate(date: string | Date): string {
return new Intl.DateTimeFormat("fr-CH", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(new Date(date))
}
export function formatDateTime(date: string | Date): string {
return new Intl.DateTimeFormat("fr-CH", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date))
}
2.9 — Routes
routes/__root.tsx
Adapter le layout dashboard-01 de shadcn avec la sidebar. Pattern:
import { createRootRoute, Outlet, Link, useNavigate, useLocation } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/router-devtools"
import { useEffect } from "react"
import { useAuth } from "@/hooks/useAuth"
import { Button } from "@/components/ui/button"
import { Toaster } from "sonner"
import {
LayoutDashboard,
Settings,
UserCircle,
LogOut,
Loader2,
type LucideIcon,
} from "lucide-react"
interface NavItem {
to: string
icon: LucideIcon
label: string
}
const NAV_LINK_CLASS = "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium hover:bg-accent [&.active]:bg-accent"
const navItems: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/settings", icon: Settings, label: "Settings" },
{ to: "/profile", icon: UserCircle, label: "Profile" },
]
function NavLink({ to, icon: Icon, label }: NavItem) {
return (
<Link to={to} className={NAV_LINK_CLASS}>
<Icon className="h-4 w-4" />
{label}
</Link>
)
}
function RootLayout() {
const { user, isAuthenticated, isLoading, logout } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const isLoginPage = location.pathname === "/login"
useEffect(() => {
if (isLoading) return
if (!isAuthenticated && !isLoginPage) {
navigate({ to: "/login" })
} else if (isAuthenticated && isLoginPage) {
navigate({ to: "/" })
}
}, [isAuthenticated, isLoading, isLoginPage, navigate])
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (isLoginPage) {
return (
<div className="min-h-screen bg-background">
<Outlet />
</div>
)
}
if (!isAuthenticated) {
return null
}
return (
<div className="flex min-h-screen">
<aside className="w-64 border-r bg-card">
<div className="flex h-16 items-center border-b px-6">
<h1 className="text-xl font-bold">{/* Nom du projet */}</h1>
</div>
<nav className="flex flex-col gap-1 p-4">
{navItems.map((item) => (
<NavLink key={item.to} {...item} />
))}
</nav>
<div className="mt-auto border-t p-4">
<div className="mb-2 text-sm text-muted-foreground">
{user?.first_name} {user?.last_name}
</div>
<Button variant="outline" size="sm" className="w-full" onClick={logout}>
<LogOut className="mr-2 h-4 w-4" />
Déconnexion
</Button>
</div>
</aside>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
<Toaster richColors position="top-right" />
<TanStackRouterDevtools />
</div>
)
}
export const Route = createRootRoute({
component: RootLayout,
})
Note: Le titre dans le <h1> doit être le nom du projet passé en argument du skill.
routes/login.tsx
Page de login avec support TOTP en deux étapes. Pattern:
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useState } from "react"
import { useAuth } from "@/hooks/useAuth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
function LoginPage() {
const navigate = useNavigate()
const { login, verifyTotp, loginPending, totpPending, loginError, totpError } = useAuth()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [totpCode, setTotpCode] = useState("")
const [tempToken, setTempToken] = useState<string | null>(null)
const [requiresTotp, setRequiresTotp] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
try {
const result = await login({ email, password })
if (result.requires_totp) {
setRequiresTotp(true)
setTempToken(result.totp_pending_token!)
} else {
navigate({ to: "/" })
}
} catch {
// Error handled by mutation
}
}
const handleTotp = async (e: React.FormEvent) => {
e.preventDefault()
if (!tempToken) return
try {
await verifyTotp({ code: totpCode, tempToken })
navigate({ to: "/" })
} catch {
// Error handled by mutation
}
}
if (requiresTotp) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Vérification 2FA</CardTitle>
<CardDescription>
Entrez le code de votre application d'authentification
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleTotp} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp">Code TOTP</Label>
<Input
id="totp"
type="text"
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
maxLength={6}
autoFocus
/>
</div>
{totpError && (
<p className="text-sm text-destructive">
{(totpError as Error).message || "Code invalide"}
</p>
)}
<Button type="submit" className="w-full" disabled={totpPending}>
{totpPending ? "Vérification..." : "Vérifier"}
</Button>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={() => {
setRequiresTotp(false)
setTempToken(null)
setTotpCode("")
}}
>
Retour
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{/* Nom du projet */}</CardTitle>
<CardDescription>Connectez-vous pour accéder à l'application</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{loginError && (
<p className="text-sm text-destructive">
{(loginError as Error).message || "Identifiants invalides"}
</p>
)}
<Button type="submit" className="w-full" disabled={loginPending}>
{loginPending ? "Connexion..." : "Se connecter"}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
export const Route = createFileRoute("/login")({
component: LoginPage,
})
routes/index.tsx (Dashboard placeholder)
import { createFileRoute } from "@tanstack/react-router"
function DashboardPage() {
return (
<div className="p-6">
<h2 className="text-2xl font-bold mb-4">Dashboard</h2>
<p className="text-muted-foreground">Bienvenue sur votre tableau de bord.</p>
</div>
)
}
export const Route = createFileRoute("/")({
component: DashboardPage,
})
routes/settings.tsx et routes/profile.tsx
Créer des pages placeholder simples avec juste un titre et un texte descriptif, en suivant le même pattern que index.tsx.
Étape 3 — Scaffold Backend
3.1 — Initialisation
cd {projet}/backend
uv init --python 3.13
uv add fastapi "uvicorn[standard]" sqlmodel asyncpg alembic "python-jose[cryptography]" "passlib[bcrypt]" pyotp "qrcode[pil]" boto3 pydantic-settings email-validator bcrypt
3.2 — Structure des fichiers
Créer la structure suivante dans {projet}/backend/:
app/
├── __init__.py
├── main.py
├── config.py
├── database.py
├── api/
│ ├── __init__.py
│ ├── deps.py
│ └── auth.py
├── models/
│ ├── __init__.py
│ └── user.py
├── schemas/
│ ├── __init__.py
│ ├── auth.py
│ └── user.py
└── services/
├── __init__.py
├── auth.py
└── s3.py
3.3 — app/config.py
Configuration simplifiée (sans bexio, github, rise, smtp, logs, slack):
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Environment
environment: str = "development"
debug: bool = False
# Database
database_url: str = "postgresql+asyncpg://app:app_dev@localhost:5432/app_dev"
# Security
secret_key: str = "dev-secret-key-change-in-production-min-32-chars"
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 7
# S3 Storage
s3_endpoint_url: str = ""
s3_access_key_id: str = ""
s3_secret_access_key: str = ""
s3_bucket_name: str = "app-storage"
# Frontend URL (for CORS)
frontend_url: str = "http://localhost:5173"
@field_validator("secret_key")
@classmethod
def validate_secret_key(cls, v: str) -> str:
if len(v) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters long")
return v
@property
def is_development(self) -> bool:
return self.environment == "development"
@property
def is_production(self) -> bool:
return self.environment == "production"
@property
def cors_origins(self) -> list[str]:
origins = [self.frontend_url]
if self.is_development:
origins.append("http://localhost:5173")
return list(set(origins))
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
3.4 — app/database.py
Identique au portail-client:
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
echo=settings.is_development,
future=True,
)
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
3.5 — app/models/user.py
Simplifié (sans relationships métier):
from datetime import datetime
from uuid import UUID, uuid4
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
__tablename__ = "users"
id: UUID = Field(default_factory=uuid4, primary_key=True)
email: str = Field(unique=True, index=True, max_length=255)
hashed_password: str = Field(max_length=255)
first_name: str = Field(max_length=100)
last_name: str = Field(max_length=100)
# TOTP 2FA
totp_secret: str | None = Field(default=None, max_length=32)
totp_enabled: bool = Field(default=False)
# Role
is_admin: bool = Field(default=False)
is_active: bool = Field(default=True)
# Optional company association
company_id: str | None = Field(default=None, max_length=100)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
last_login_at: datetime | None = Field(default=None)
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
Note: company_id est un simple string ici, pas une FK. Pas de Relationship car pas de table Company dans le scaffold.
3.6 — app/schemas/auth.py
Identique au portail-client:
from pydantic import BaseModel, EmailStr, Field
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
class LoginResponse(BaseModel):
access_token: str | None = None
refresh_token: str | None = None
token_type: str = "bearer"
requires_totp: bool = False
totp_pending_token: str | None = None
class TOTPVerifyRequest(BaseModel):
totp_pending_token: str
code: str = Field(min_length=6, max_length=6)
class RefreshTokenRequest(BaseModel):
refresh_token: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TOTPSetupResponse(BaseModel):
secret: str
qr_code_base64: str
provisioning_uri: str
class TOTPEnableRequest(BaseModel):
code: str = Field(min_length=6, max_length=6)
class MessageResponse(BaseModel):
message: str
3.7 — app/schemas/user.py
Simplifié:
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class UserBase(BaseModel):
email: EmailStr
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
class UserCreate(UserBase):
password: str = Field(min_length=8)
company_id: str | None = None
is_admin: bool = False
class UserUpdate(BaseModel):
email: EmailStr | None = None
first_name: str | None = Field(default=None, min_length=1, max_length=100)
last_name: str | None = Field(default=None, min_length=1, max_length=100)
is_active: bool | None = None
class UserResponse(UserBase):
id: UUID
company_id: str | None
is_admin: bool
is_active: bool
totp_enabled: bool
created_at: datetime
last_login_at: datetime | None
class Config:
from_attributes = True
class UserMeResponse(UserResponse):
pass
3.8 — app/services/auth.py
Identique au portail-client:
import io
from datetime import datetime, timedelta
from typing import Any
import bcrypt
import pyotp
import qrcode
from jose import JWTError, jwt
from app.config import settings
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
def get_password_hash(password: str) -> str:
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def create_access_token(data: dict[str, Any]) -> str:
to_encode = data.copy()
to_encode["type"] = "access"
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(data: dict[str, Any]) -> str:
to_encode = data.copy()
to_encode["type"] = "refresh"
expire = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def create_totp_pending_token(user_id: str) -> str:
to_encode = {"sub": user_id, "type": "totp_pending"}
expire = datetime.utcnow() + timedelta(minutes=5)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict[str, Any] | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload
except JWTError:
return None
def generate_totp_secret() -> str:
return pyotp.random_base32()
def get_totp_uri(secret: str, email: str, issuer: str = "My App") -> str:
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=email, issuer_name=issuer)
def generate_totp_qr_code(secret: str, email: str) -> bytes:
uri = get_totp_uri(secret, email)
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
return buffer.getvalue()
def verify_totp(secret: str, code: str) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
Note: L'issuer dans get_totp_uri doit être remplacé par le nom du projet.
3.9 — app/services/s3.py
Générique (sans méthodes métier: upload_company_document, upload_ticket_attachment, upload_offer_pdf, upload_invoice_pdf):
from io import BytesIO
from typing import Any, BinaryIO
from uuid import uuid4
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from app.config import settings
class S3Error(Exception):
def __init__(self, message: str, operation: str | None = None):
self.message = message
self.operation = operation
super().__init__(self.message)
class S3Service:
def __init__(
self,
endpoint_url: str | None = None,
access_key_id: str | None = None,
secret_access_key: str | None = None,
bucket_name: str | None = None,
):
self.endpoint_url = endpoint_url or settings.s3_endpoint_url
self.access_key_id = access_key_id or settings.s3_access_key_id
self.secret_access_key = secret_access_key or settings.s3_secret_access_key
self.bucket_name = bucket_name or settings.s3_bucket_name
self._client: Any = None
def _get_client(self) -> Any:
if self._client is not None:
return self._client
if not self.endpoint_url:
raise S3Error("S3 endpoint URL not configured", operation="init")
self._client = boto3.client(
"s3",
endpoint_url=self.endpoint_url,
region_name="us-east-1",
aws_access_key_id=self.access_key_id,
aws_secret_access_key=self.secret_access_key,
config=Config(
signature_version="s3v4",
s3={"addressing_style": "path"},
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
),
)
return self._client
def _generate_key(self, prefix: str, filename: str) -> str:
ext = filename.rsplit(".", 1)[-1] if "." in filename else ""
unique_id = uuid4().hex[:8]
safe_filename = f"{unique_id}.{ext}" if ext else unique_id
return f"{prefix}/{safe_filename}"
def upload_file(
self,
file: BinaryIO,
key: str,
content_type: str = "application/octet-stream",
) -> str:
try:
self._get_client().put_object(
Bucket=self.bucket_name,
Key=key,
Body=file.read(),
ContentType=content_type,
)
return key
except ClientError as e:
raise S3Error(f"Upload failed: {e}", operation="upload") from e
def upload_bytes(
self,
data: bytes,
key: str,
content_type: str = "application/octet-stream",
) -> str:
return self.upload_file(BytesIO(data), key, content_type)
def get_presigned_url(
self,
key: str,
expires_in: int = 3600,
response_content_disposition: str | None = None,
) -> str:
try:
params: dict[str, Any] = {"Bucket": self.bucket_name, "Key": key}
if response_content_disposition:
params["ResponseContentDisposition"] = response_content_disposition
return self._get_client().generate_presigned_url(
"get_object",
Params=params,
ExpiresIn=expires_in,
)
except ClientError as e:
raise S3Error(f"Failed to generate presigned URL: {e}", operation="presign") from e
def download_file(self, key: str) -> bytes:
try:
response = self._get_client().get_object(Bucket=self.bucket_name, Key=key)
return response["Body"].read()
except ClientError as e:
raise S3Error(f"Download failed: {e}", operation="download") from e
def delete_file(self, key: str) -> bool:
try:
self._get_client().delete_object(Bucket=self.bucket_name, Key=key)
return True
except ClientError as e:
raise S3Error(f"Delete failed: {e}", operation="delete") from e
def file_exists(self, key: str) -> bool:
try:
self._get_client().head_object(Bucket=self.bucket_name, Key=key)
return True
except ClientError:
return False
def get_file_info(self, key: str) -> dict[str, Any]:
try:
response = self._get_client().head_object(Bucket=self.bucket_name, Key=key)
return {
"content_type": response.get("ContentType"),
"content_length": response.get("ContentLength"),
"last_modified": response.get("LastModified"),
"etag": response.get("ETag"),
}
except ClientError as e:
raise S3Error(f"Failed to get file info: {e}", operation="head") from e
s3_service = S3Service()
3.10 — app/api/deps.py
Sans VpsAgent:
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from app.database import get_session
from app.models.user import User
from app.services.auth import decode_token
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
payload = decode_token(token)
if payload is None:
raise credentials_exception
token_type = payload.get("type")
if token_type != "access":
raise credentials_exception
user_id = payload.get("sub")
if user_id is None:
raise credentials_exception
try:
user_uuid = UUID(user_id)
except ValueError:
raise credentials_exception
result = await session.execute(select(User).where(User.id == user_uuid))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
return user
async def get_current_admin_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return current_user
async def get_current_client_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
if current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin users cannot access client portal",
)
return current_user
# Type aliases
CurrentUser = Annotated[User, Depends(get_current_user)]
AdminUser = Annotated[User, Depends(get_current_admin_user)]
ClientUser = Annotated[User, Depends(get_current_client_user)]
DbSession = Annotated[AsyncSession, Depends(get_session)]
3.11 — app/api/auth.py
Endpoints d'authentification. Identique au portail-client mais sans le lazy load de company:
import base64
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from sqlmodel import select
from app.api.deps import CurrentUser, DbSession
from app.models.user import User
from app.schemas.auth import (
LoginRequest,
LoginResponse,
MessageResponse,
RefreshTokenRequest,
TokenResponse,
TOTPEnableRequest,
TOTPSetupResponse,
TOTPVerifyRequest,
)
from app.schemas.user import UserMeResponse
from app.services.auth import (
create_access_token,
create_refresh_token,
create_totp_pending_token,
decode_token,
generate_totp_qr_code,
generate_totp_secret,
get_totp_uri,
verify_password,
verify_totp,
)
router = APIRouter()
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, session: DbSession) -> LoginResponse:
result = await session.execute(select(User).where(User.email == request.email))
user = result.scalar_one_or_none()
if user is None or not verify_password(request.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
if user.totp_enabled and user.totp_secret:
totp_pending_token = create_totp_pending_token(str(user.id))
return LoginResponse(
requires_totp=True,
totp_pending_token=totp_pending_token,
)
user.last_login_at = datetime.utcnow()
session.add(user)
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
)
@router.post("/verify-totp", response_model=TokenResponse)
async def verify_totp_code(request: TOTPVerifyRequest, session: DbSession) -> TokenResponse:
payload = decode_token(request.totp_pending_token)
if payload is None or payload.get("type") != "totp_pending":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired TOTP token",
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
try:
user_uuid = UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
result = await session.execute(select(User).where(User.id == user_uuid))
user = result.scalar_one_or_none()
if user is None or not user.totp_secret:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
if not verify_totp(user.totp_secret, request.code):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid TOTP code",
)
user.last_login_at = datetime.utcnow()
session.add(user)
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(request: RefreshTokenRequest, session: DbSession) -> TokenResponse:
payload = decode_token(request.refresh_token)
if payload is None or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
try:
user_uuid = UUID(user_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
result = await session.execute(select(User).where(User.id == user_uuid))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
)
@router.get("/me", response_model=UserMeResponse)
async def get_current_user_info(current_user: CurrentUser) -> UserMeResponse:
return UserMeResponse(
id=current_user.id,
email=current_user.email,
first_name=current_user.first_name,
last_name=current_user.last_name,
company_id=current_user.company_id,
is_admin=current_user.is_admin,
is_active=current_user.is_active,
totp_enabled=current_user.totp_enabled,
created_at=current_user.created_at,
last_login_at=current_user.last_login_at,
)
@router.post("/setup-totp", response_model=TOTPSetupResponse)
async def setup_totp(current_user: CurrentUser, session: DbSession) -> TOTPSetupResponse:
if current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TOTP is already enabled",
)
secret = generate_totp_secret()
current_user.totp_secret = secret
session.add(current_user)
qr_code_bytes = generate_totp_qr_code(secret, current_user.email)
qr_code_base64 = base64.b64encode(qr_code_bytes).decode("utf-8")
return TOTPSetupResponse(
secret=secret,
qr_code_base64=qr_code_base64,
provisioning_uri=get_totp_uri(secret, current_user.email),
)
@router.post("/enable-totp", response_model=MessageResponse)
async def enable_totp(
request: TOTPEnableRequest,
current_user: CurrentUser,
session: DbSession,
) -> MessageResponse:
if current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TOTP is already enabled",
)
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TOTP not set up. Call /setup-totp first.",
)
if not verify_totp(current_user.totp_secret, request.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid TOTP code",
)
current_user.totp_enabled = True
session.add(current_user)
return MessageResponse(message="TOTP enabled successfully")
@router.post("/disable-totp", response_model=MessageResponse)
async def disable_totp(
request: TOTPEnableRequest,
current_user: CurrentUser,
session: DbSession,
) -> MessageResponse:
if not current_user.totp_enabled or not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TOTP is not enabled",
)
if not verify_totp(current_user.totp_secret, request.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid TOTP code",
)
current_user.totp_enabled = False
current_user.totp_secret = None
session.add(current_user)
return MessageResponse(message="TOTP disabled successfully")
3.12 — app/main.py
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.database import init_db
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
if settings.is_development:
await init_db()
yield
app = FastAPI(
title="My App API",
version="0.1.0",
lifespan=lifespan,
docs_url="/docs" if settings.is_development else None,
redoc_url="/redoc" if settings.is_development else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health_check() -> dict[str, str]:
return {"status": "healthy"}
from app.api.auth import router as auth_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
Note: Le titre de l'API doit être adapté au nom du projet.
3.13 — Alembic
Initialiser Alembic pour les migrations async:
cd {projet}/backend
uv run alembic init -t async alembic
Modifier alembic/env.py pour importer les models et utiliser l'URL depuis config:
- •Importer
from app.config import settingsetfrom app.models.user import User - •Setter
target_metadata = User.metadata(ouSQLModel.metadata) - •Utiliser
settings.database_urlpour la connexion
Modifier alembic.ini:
- •Commenter la ligne
sqlalchemy.url(elle vient du code)
Créer la première migration:
uv run alembic revision --autogenerate -m "initial"
Étape 4 — DevOps
4.1 — docker-compose.dev.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app_dev
POSTGRES_DB: app_dev
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
4.2 — Makefile
.PHONY: install dev backend-dev frontend-dev migrate seed install: cd backend && uv sync cd frontend && npm install dev: $(MAKE) -j2 backend-dev frontend-dev backend-dev: cd backend && uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 frontend-dev: cd frontend && npm run dev migrate: cd backend && uv run alembic upgrade head seed: cd backend && uv run python scripts/seed.py
4.3 — .env.example
# Database DATABASE_URL=postgresql+asyncpg://app:app_dev@localhost:5432/app_dev # Security SECRET_KEY=change-this-to-a-random-string-at-least-32-characters-long # S3 Storage (optional) S3_ENDPOINT_URL= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_BUCKET_NAME=app-storage # Frontend FRONTEND_URL=http://localhost:5173
4.4 — .gitignore
# Python __pycache__/ *.py[cod] *.egg-info/ .venv/ dist/ # Node node_modules/ dist/ # Environment .env .env.local # IDE .vscode/ .idea/ # OS .DS_Store Thumbs.db # Alembic alembic/versions/*.pyc
Étape 5 — Script de seed
Créer {projet}/backend/scripts/seed.py:
"""Seed script to create initial users."""
import asyncio
import sys
from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlmodel import select
from app.database import async_session_maker, engine, init_db
from app.models.user import User
from app.services.auth import get_password_hash
async def seed() -> None:
"""Create initial admin and client users."""
await init_db()
async with async_session_maker() as session:
# Check if admin already exists
result = await session.execute(
select(User).where(User.email == "admin@example.com")
)
if result.scalar_one_or_none():
print("Seed data already exists, skipping.")
return
admin = User(
email="admin@example.com",
hashed_password=get_password_hash("Admin123!"),
first_name="Admin",
last_name="User",
is_admin=True,
is_active=True,
)
client = User(
email="client@example.com",
hashed_password=get_password_hash("Client123!"),
first_name="Client",
last_name="User",
is_admin=False,
is_active=True,
company_id="demo-corp",
)
session.add(admin)
session.add(client)
await session.commit()
print("Seed completed:")
print(" Admin: admin@example.com / Admin123!")
print(" Client: client@example.com / Client123!")
if __name__ == "__main__":
asyncio.run(seed())
Vérification post-scaffold
Après création, vérifier:
- •
cd {projet}/frontend && npm install && npm run dev→ Dashboard shadcn avec Gilroy sur http://localhost:5173 - •
cd {projet}/backend && uv sync→ Dépendances installées - •
docker compose -f docker-compose.dev.yml up -d→ PostgreSQL démarre - •
cd {projet}/backend && uv run alembic upgrade head→ Tables créées - •
cd {projet}/backend && uv run python scripts/seed.py→ Users seedés - •
cd {projet}/backend && uv run uvicorn app.main:app --reload→ API /health OK - •Frontend login → Auth fonctionnelle
- •Sidebar affiche Dashboard, Settings, Profile