feat(r0.10): apps/api — auth fermée par défaut, matrice en base, démo-login ADR-002, seed

- packages/shared : rôles/catégories, schémas Zod, contrat d'API ;
  pnpm contract → docs/openapi.json committée (règle d'or ADR-001)
- apps/api : NestJS 11 + Prisma 6, migration r0_identity (Role/Permission/User) ;
  guard JWT global + @Public() ; PermissionsGuard (@RequirePermission,
  matrice relue en base, cache 60 s) ; FileStorage (seul import MinIO) ; /health
- démo-login ADR-002 : module conditionnel DEMO_MODE (404 sinon, testé e2e),
  double verrou production, refus des comptes isDemo=false
- seed idempotent : 7 rôles, matrice complète (70 lignes), 7 comptes démo
- 19 tests Jest (unit + e2e) ; smoke test sur build de prod

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-15 21:55:04 +01:00
parent ddc9dc52b5
commit 8adb561b63
62 changed files with 8509 additions and 2 deletions

View File

@@ -0,0 +1,41 @@
import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { DemoAuthModule } from './auth/demo/demo-auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { demoModeEnabled } from './config/env';
import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module';
import { PermissionsGuard } from './permissions/permissions.guard';
import { PermissionsModule } from './permissions/permissions.module';
import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './users/users.module';
/**
* Composé via forRoot() (et non statiquement) pour que l'enregistrement
* conditionnel de DemoAuthModule (ADR-002) soit décidé au démarrage —
* et testable dans les deux états (module présent / routes 404).
*/
@Module({})
export class AppModule {
static forRoot(): DynamicModule {
return {
module: AppModule,
imports: [
PrismaModule,
PermissionsModule,
FilesModule,
AuthModule,
UsersModule,
HealthModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []),
],
providers: [
// Ordre significatif : authentification puis permissions
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
};
}
}

View File

@@ -0,0 +1,17 @@
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@HttpCode(200)
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
return this.authService.login(body.email, body.password);
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
import { loadEnv } from '../config/env';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [
JwtModule.registerAsync({
global: true,
useFactory: () => {
const env = loadEnv();
return {
secret: env.JWT_SECRET,
signOptions: {
expiresIn: env.JWT_EXPIRES_IN as JwtSignOptions['expiresIn'],
},
};
},
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,104 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type {
AuthResponse,
DemoAccountsResponse,
RoleName,
} from '@siop/shared';
import * as argon2 from 'argon2';
import { PrismaService } from '../prisma/prisma.service';
type UserWithRole = {
id: string;
email: string;
displayName: string;
isDemo: boolean;
role: { id: string; name: string };
};
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
) {}
async login(email: string, password: string): Promise<AuthResponse> {
const user = await this.prisma.user.findUnique({
where: { email },
include: { role: true },
});
// argon2.verify même sur compte inconnu = temps de réponse homogène
const valid =
user?.passwordHash != null &&
(await argon2.verify(user.passwordHash, password));
if (!user || !user.isActive || !valid) {
throw new UnauthorizedException('Identifiants invalides');
}
return this.issueToken(user);
}
/** ADR-002 — liste publique en DEMO_MODE, jamais de secret. */
async listDemoAccounts(): Promise<DemoAccountsResponse> {
const users = await this.prisma.user.findMany({
where: { isDemo: true, isActive: true },
include: { role: true },
orderBy: { createdAt: 'asc' },
});
return {
accounts: users.map((u) => ({
id: u.id,
displayName: u.displayName,
roleName: u.role.name as RoleName,
initials: this.initials(u.displayName),
})),
};
}
/** ADR-002 — refuse tout compte isDemo=false, quoi qu'il arrive. */
async demoLogin(userId: string): Promise<AuthResponse> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { role: true },
});
if (!user || !user.isActive) throw new NotFoundException('Compte inconnu');
if (!user.isDemo) {
throw new ForbiddenException(
'Ce compte nest pas un compte de démonstration',
);
}
return this.issueToken(user);
}
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
// Identité seule — les droits restent en base (invariant R0)
const accessToken = await this.jwtService.signAsync({
sub: user.id,
roleId: user.role.id,
});
return {
accessToken,
user: {
id: user.id,
email: user.email,
displayName: user.displayName,
role: { id: user.role.id, name: user.role.name as RoleName },
isDemo: user.isDemo,
},
};
}
private initials(displayName: string): string {
return displayName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]!.toUpperCase())
.join('');
}
}

View File

@@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/** Identité portée par le JWT — jamais de droits (invariant R0) :
* la matrice est relue en base par PermissionsGuard. */
export interface AuthenticatedUser {
userId: string;
roleId: string;
}
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext): AuthenticatedUser => {
return ctx.switchToHttp().getRequest().user;
},
);

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import { DemoLoginRequestSchema, type DemoLoginRequest } from '@siop/shared';
import { ZodValidationPipe } from '../../common/zod-validation.pipe';
import { AuthService } from '../auth.service';
import { Public } from '../public.decorator';
/** ADR-002 — ces routes n'existent que si DemoAuthModule est enregistré
* (DEMO_MODE=true) ; sinon : 404, vérifié par test e2e. */
@Controller('auth')
export class DemoAuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Get('demo-accounts')
listDemoAccounts() {
return this.authService.listDemoAccounts();
}
@Public()
@Post('demo-login')
@HttpCode(200)
demoLogin(
@Body(new ZodValidationPipe(DemoLoginRequestSchema)) body: DemoLoginRequest,
) {
return this.authService.demoLogin(body.userId);
}
}

View File

@@ -0,0 +1,27 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { assertDemoModeAllowed } from '../../config/env';
import { AuthModule } from '../auth.module';
import { DemoAuthController } from './demo-auth.controller';
/**
* ADR-002 : module enregistré UNIQUEMENT quand DEMO_MODE=true
* (voir AppModule.forRoot). Double verrou : refuse de démarrer en production
* sans DEMO_MODE_I_KNOW=true.
*/
@Module({
imports: [AuthModule],
controllers: [DemoAuthController],
})
export class DemoAuthModule implements OnModuleInit {
private readonly logger = new Logger(DemoAuthModule.name);
constructor() {
assertDemoModeAllowed();
}
onModuleInit() {
this.logger.warn(
'DEMO_MODE actif — /auth/demo-accounts et /auth/demo-login sont exposées.',
);
}
}

View File

@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import { IS_PUBLIC_KEY } from './public.decorator';
/** Guard global : API fermée par défaut, ouverture explicite via @Public(). */
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<Request>();
const [scheme, token] = request.headers.authorization?.split(' ') ?? [];
if (scheme !== 'Bearer' || !token) {
throw new UnauthorizedException('Jeton manquant');
}
try {
const payload = await this.jwtService.verifyAsync<{
sub: string;
roleId: string;
}>(token);
(request as Request & { user: unknown }).user = {
userId: payload.sub,
roleId: payload.roleId,
};
return true;
} catch {
throw new UnauthorizedException('Jeton invalide ou expiré');
}
}
}

View File

@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/** L'API est fermée par défaut (guard JWT global) : toute route publique
* doit l'être EXPLICITEMENT via ce décorateur. */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,22 @@
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
import type { z } from 'zod';
/** Valide un body contre un schéma du contrat (@siop/shared). */
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: z.ZodType) {}
transform(value: unknown) {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: 'Corps de requête invalide',
issues: result.error.issues.map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
});
}
return result.data;
}
}

View File

@@ -0,0 +1,37 @@
import { assertDemoModeAllowed, demoModeEnabled } from './env';
describe('ADR-002 — double verrou DEMO_MODE', () => {
const saved = { ...process.env };
afterEach(() => {
process.env = { ...saved };
});
it('refuse DEMO_MODE=true en production sans DEMO_MODE_I_KNOW', () => {
process.env.DEMO_MODE = 'true';
process.env.NODE_ENV = 'production';
delete process.env.DEMO_MODE_I_KNOW;
expect(() => assertDemoModeAllowed()).toThrow(/ADR-002/);
});
it('accepte DEMO_MODE=true en production AVEC DEMO_MODE_I_KNOW=true (instance démo)', () => {
process.env.DEMO_MODE = 'true';
process.env.NODE_ENV = 'production';
process.env.DEMO_MODE_I_KNOW = 'true';
expect(() => assertDemoModeAllowed()).not.toThrow();
});
it('accepte DEMO_MODE=true hors production', () => {
process.env.DEMO_MODE = 'true';
process.env.NODE_ENV = 'development';
expect(() => assertDemoModeAllowed()).not.toThrow();
});
it("demoModeEnabled ne s'active que sur la valeur exacte « true »", () => {
process.env.DEMO_MODE = '1';
expect(demoModeEnabled()).toBe(false);
process.env.DEMO_MODE = 'true';
expect(demoModeEnabled()).toBe(true);
delete process.env.DEMO_MODE;
expect(demoModeEnabled()).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
import 'dotenv/config';
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().min(1),
REDIS_URL: z.string().default('redis://localhost:6379'),
JWT_SECRET: z.string().min(16, 'JWT_SECRET : 16 caractères minimum'),
JWT_EXPIRES_IN: z.string().default('8h'),
MINIO_ENDPOINT: z.string().default('localhost'),
MINIO_PORT: z.coerce.number().default(9000),
MINIO_USE_SSL: z.string().optional(),
MINIO_ACCESS_KEY: z.string().default('siop'),
MINIO_SECRET_KEY: z.string().default('siop-minio'),
});
export type Env = z.infer<typeof EnvSchema>;
/** Validée au démarrage — l'API refuse de booter sur une config invalide. */
export function loadEnv(): Env {
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(
`Configuration invalide :\n${parsed.error.issues
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
.join('\n')}`,
);
}
return parsed.data;
}
/** Lues à l'appel (et non à l'import) pour rester testables. */
export function demoModeEnabled(): boolean {
return process.env.DEMO_MODE === 'true';
}
/**
* ADR-002, double verrou : DEMO_MODE=true en production exige l'aveu explicite
* DEMO_MODE_I_KNOW=true (réservé à l'instance de démonstration publique).
*/
export function assertDemoModeAllowed(): void {
if (
demoModeEnabled() &&
process.env.NODE_ENV === 'production' &&
process.env.DEMO_MODE_I_KNOW !== 'true'
) {
throw new Error(
'DEMO_MODE=true en production sans DEMO_MODE_I_KNOW=true — démarrage refusé (ADR-002).',
);
}
}

View File

@@ -0,0 +1,12 @@
/**
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
* les opérations arrivent avec les photos/documents (R2-R3).
*/
export const FILE_STORAGE = Symbol('FILE_STORAGE');
export interface FileStorage {
/** Lève une exception si le stockage est injoignable. */
healthCheck(): Promise<void>;
}

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { FILE_STORAGE } from './file-storage';
import { MinioStorageService } from './minio-storage.service';
@Global()
@Module({
providers: [{ provide: FILE_STORAGE, useClass: MinioStorageService }],
exports: [FILE_STORAGE],
})
export class FilesModule {}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import * as Minio from 'minio';
import { loadEnv } from '../config/env';
import type { FileStorage } from './file-storage';
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
@Injectable()
export class MinioStorageService implements FileStorage {
private readonly client: Minio.Client;
constructor() {
const env = loadEnv();
this.client = new Minio.Client({
endPoint: env.MINIO_ENDPOINT,
port: env.MINIO_PORT,
useSSL: env.MINIO_USE_SSL === 'true',
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
});
}
async healthCheck(): Promise<void> {
await this.client.listBuckets();
}
}

View File

@@ -0,0 +1,53 @@
import { Controller, Get, Inject } from '@nestjs/common';
import type { HealthResponse } from '@siop/shared';
import Redis from 'ioredis';
import { Public } from '../auth/public.decorator';
import { loadEnv } from '../config/env';
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
import { PrismaService } from '../prisma/prisma.service';
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
) {}
@Public()
@Get()
async health(): Promise<HealthResponse> {
const [database, redis, storage] = await Promise.all([
this.check(() => this.prisma.$queryRaw`SELECT 1`),
this.check(async () => {
const client = new Redis(loadEnv().REDIS_URL, {
lazyConnect: true,
maxRetriesPerRequest: 0,
connectTimeout: 2000,
});
try {
await client.connect();
await client.ping();
} finally {
client.disconnect();
}
}),
this.check(() => this.storage.healthCheck()),
]);
const services = { database, redis, storage };
return {
status: Object.values(services).every((s) => s === 'up')
? 'ok'
: 'degraded',
services,
};
}
private async check(fn: () => Promise<unknown>): Promise<'up' | 'down'> {
try {
await fn();
return 'up';
} catch {
return 'down';
}
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

17
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,17 @@
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { assertDemoModeAllowed, loadEnv } from './config/env';
async function bootstrap() {
const env = loadEnv();
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
const app = await NestFactory.create(AppModule.forRoot());
app.enableShutdownHooks();
await app.listen(env.PORT);
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
}
void bootstrap();

View File

@@ -0,0 +1,48 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import {
PERMISSION_KEY,
RequiredPermission,
} from './require-permission.decorator';
import { PermissionsService } from './permissions.service';
/** Second guard global (après JwtAuthGuard) : applique @RequirePermission
* en relisant la matrice EN BASE — le JWT ne porte jamais de droits. */
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly permissionsService: PermissionsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<RequiredPermission>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
if (!required) return true; // pas d'exigence déclarée (ex. routes @Public)
const user: AuthenticatedUser | undefined = context
.switchToHttp()
.getRequest().user;
if (!user) throw new ForbiddenException();
const allowed = await this.permissionsService.can(
user.roleId,
required.category,
required.right,
);
if (!allowed) {
throw new ForbiddenException(
`Droit manquant : ${required.category}.${required.right}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PermissionsService } from './permissions.service';
@Global()
@Module({
providers: [PermissionsService],
exports: [PermissionsService],
})
export class PermissionsModule {}

View File

@@ -0,0 +1,62 @@
import type { PrismaService } from '../prisma/prisma.service';
import { PermissionsService } from './permissions.service';
const row = (overrides: Partial<Record<string, unknown>> = {}) => ({
id: 'p1',
roleId: 'role-1',
objectCategory: 'WORK_ORDERS',
canView: true,
canViewOther: false,
canCreate: false,
canEdit: true,
canDelete: false,
...overrides,
});
describe('PermissionsService (matrice en base, cache 60 s)', () => {
let findMany: jest.Mock;
let service: PermissionsService;
beforeEach(() => {
jest.useFakeTimers();
findMany = jest.fn().mockResolvedValue([row()]);
service = new PermissionsService({
permission: { findMany },
} as unknown as PrismaService);
});
afterEach(() => jest.useRealTimers());
it('applique les cinq droits de la ligne', async () => {
await expect(service.can('role-1', 'WORK_ORDERS', 'view')).resolves.toBe(true);
await expect(service.can('role-1', 'WORK_ORDERS', 'edit')).resolves.toBe(true);
await expect(service.can('role-1', 'WORK_ORDERS', 'viewOther')).resolves.toBe(false);
await expect(service.can('role-1', 'WORK_ORDERS', 'create')).resolves.toBe(false);
await expect(service.can('role-1', 'WORK_ORDERS', 'delete')).resolves.toBe(false);
});
it('refuse une catégorie sans ligne (fermé par défaut)', async () => {
await expect(service.can('role-1', 'SETTINGS', 'view')).resolves.toBe(false);
});
it('ne relit la base quà lexpiration du cache (60 s)', async () => {
await service.can('role-1', 'WORK_ORDERS', 'view');
await service.can('role-1', 'WORK_ORDERS', 'edit');
expect(findMany).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(59_000);
await service.can('role-1', 'WORK_ORDERS', 'view');
expect(findMany).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(2_000);
await service.can('role-1', 'WORK_ORDERS', 'view');
expect(findMany).toHaveBeenCalledTimes(2);
});
it('invalidate() force la relecture immédiate', async () => {
await service.can('role-1', 'WORK_ORDERS', 'view');
service.invalidate('role-1');
await service.can('role-1', 'WORK_ORDERS', 'view');
expect(findMany).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import type {
ObjectCategory,
PermissionEntry,
PermissionRight,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const CACHE_TTL_MS = 60_000; // invariant R0 : la matrice est relue au plus toutes les 60 s
interface CacheEntry {
expiresAt: number;
permissions: PermissionEntry[];
}
@Injectable()
export class PermissionsService {
private readonly cache = new Map<string, CacheEntry>();
constructor(private readonly prisma: PrismaService) {}
async getForRole(roleId: string): Promise<PermissionEntry[]> {
const cached = this.cache.get(roleId);
if (cached && cached.expiresAt > Date.now()) return cached.permissions;
const rows = await this.prisma.permission.findMany({ where: { roleId } });
const permissions = rows.map((r) => ({
objectCategory: r.objectCategory as ObjectCategory,
canView: r.canView,
canViewOther: r.canViewOther,
canCreate: r.canCreate,
canEdit: r.canEdit,
canDelete: r.canDelete,
}));
this.cache.set(roleId, { expiresAt: Date.now() + CACHE_TTL_MS, permissions });
return permissions;
}
async can(
roleId: string,
category: ObjectCategory,
right: PermissionRight,
): Promise<boolean> {
const permissions = await this.getForRole(roleId);
const entry = permissions.find((p) => p.objectCategory === category);
if (!entry) return false;
switch (right) {
case 'view':
return entry.canView;
case 'viewOther':
return entry.canViewOther;
case 'create':
return entry.canCreate;
case 'edit':
return entry.canEdit;
case 'delete':
return entry.canDelete;
}
}
/** À appeler après toute modification de la matrice (admin, R1+). */
invalidate(roleId?: string): void {
if (roleId) this.cache.delete(roleId);
else this.cache.clear();
}
}

View File

@@ -0,0 +1,16 @@
import { SetMetadata } from '@nestjs/common';
import type { ObjectCategory, PermissionRight } from '@siop/shared';
export const PERMISSION_KEY = 'requiredPermission';
export interface RequiredPermission {
category: ObjectCategory;
right: PermissionRight;
}
/** Exige un droit de la matrice (relue en base, cache 60 s) :
* @RequirePermission('WORK_ORDERS', 'create') */
export const RequirePermission = (
category: ObjectCategory,
right: PermissionRight,
) => SetMetadata(PERMISSION_KEY, { category, right } satisfies RequiredPermission);

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,34 @@
import { Controller, Get, NotFoundException } from '@nestjs/common';
import type { MeResponse, RoleName } from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
@Controller('users')
export class UsersController {
constructor(
private readonly prisma: PrismaService,
private readonly permissionsService: PermissionsService,
) {}
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */
@Get('me')
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
const user = await this.prisma.user.findUnique({
where: { id: current.userId },
include: { role: true },
});
if (!user || !user.isActive) throw new NotFoundException();
return {
id: user.id,
email: user.email,
displayName: user.displayName,
role: { id: user.role.id, name: user.role.name as RoleName },
isDemo: user.isDemo,
permissions: await this.permissionsService.getForRole(user.roleId),
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
})
export class UsersModule {}