merge: Azure SSO integration

This commit is contained in:
SaidSoighiri94
2026-07-23 16:09:20 +02:00
26 changed files with 2166 additions and 141 deletions
+5 -1
View File
@@ -9,5 +9,9 @@ DB_NAME=eme_db
DB_USER=sa
DB_PASSWORD=YourStrongPassword123!
AUTH_MODE=demo
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
AZURE_API_CLIENT_ID=
AZURE_API_AUDIENCE=
AZURE_API_SCOPE=access_as_user
AZURE_ALLOWED_EMAIL_DOMAINS=ensup.eu,ensitech.eu
+58 -3
View File
@@ -14,7 +14,8 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3"
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -1068,7 +1069,6 @@
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
@@ -1079,7 +1079,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -3074,6 +3073,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
"integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-md4": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
@@ -3134,6 +3142,23 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz",
"integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^6.1.3",
"limiter": "^1.1.5",
"lru-cache": "^11.0.0",
"lru-memoizer": "^3.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
@@ -3168,6 +3193,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3184,6 +3214,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -3233,6 +3269,25 @@
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lru-memoizer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz",
"integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^11.0.1"
}
},
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
+2 -1
View File
@@ -43,6 +43,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3"
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0"
}
}
+55 -3
View File
@@ -2,6 +2,8 @@ import dotenv from 'dotenv';
dotenv.config();
type AuthMode = 'demo' | 'azure';
function required(name: string): string {
const value = process.env[name];
@@ -28,9 +30,50 @@ function numberFromEnv(name: string, defaultValue: number): number {
return parsed;
}
function authModeFromEnv(nodeEnv: string): AuthMode {
const value = process.env.AUTH_MODE || (nodeEnv === 'production' ? 'azure' : 'demo');
if (value !== 'demo' && value !== 'azure') {
throw new Error('Variable denvironnement invalide: AUTH_MODE');
}
if (nodeEnv === 'production' && value === 'demo') {
throw new Error('AUTH_MODE=demo est interdit en production');
}
return value;
}
function azureValue(name: string, authMode: AuthMode): string {
const value = process.env[name];
if (authMode === 'azure' && !value) {
throw new Error(`Variable d'environnement manquante en mode Azure: ${name}`);
}
return value || '';
}
function listFromEnv(name: string, defaultValue: string[]): string[] {
const value = process.env[name];
if (!value) {
return defaultValue;
}
return value
.split(',')
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0);
}
const nodeEnv = process.env.NODE_ENV || 'development';
const authMode = authModeFromEnv(nodeEnv);
const azureApiClientId = azureValue('AZURE_API_CLIENT_ID', authMode);
export const env = {
port: numberFromEnv('PORT', 3000),
nodeEnv: process.env.NODE_ENV || 'development',
nodeEnv,
frontendUrl: required('FRONTEND_URL'),
databaseUrl: required('DATABASE_URL'),
db: {
@@ -40,8 +83,17 @@ export const env = {
user: required('DB_USER'),
password: required('DB_PASSWORD'),
},
auth: {
mode: authMode,
azure: {
tenantId: required('AZURE_TENANT_ID'),
clientId: required('AZURE_CLIENT_ID'),
tenantId: azureValue('AZURE_TENANT_ID', authMode),
apiClientId: azureApiClientId,
audience: process.env.AZURE_API_AUDIENCE || azureApiClientId,
scope: process.env.AZURE_API_SCOPE || 'access_as_user',
allowedEmailDomains: listFromEnv('AZURE_ALLOWED_EMAIL_DOMAINS', [
'ensup.eu',
'ensitech.eu',
]),
},
},
};
+5 -23
View File
@@ -1,28 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/app-error';
import { findByEmail } from '../repositories/utilisateur.repository';
import { AuthenticatedUser } from '../types/authenticated-user';
import { authenticateUser } from '../services/authentication.service';
/* Auth simulée (temporaire) : en attendant Azure AD, l'utilisateur courant est
résolu depuis l'en-tête `x-user-email`. À remplacer par la validation du JWT. */
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
const email = req.header('x-user-email');
if (!email) {
throw new AppError(401, 'En-tete x-user-email requis (auth simulee)');
}
const utilisateur = await findByEmail(email);
if (!utilisateur || !utilisateur.actif) {
throw new AppError(401, 'Utilisateur non reconnu');
}
const authenticated: AuthenticatedUser = {
id: utilisateur.id,
email: utilisateur.email,
roleCode: utilisateur.role.code,
campusId: utilisateur.campusId,
};
req.user = authenticated;
req.user = await authenticateUser({
authorization: req.header('authorization'),
demoEmail: req.header('x-user-email'),
});
next();
}
@@ -14,6 +14,13 @@ export function findByEmail(email: string): Promise<UtilisateurAvecRole | null>
});
}
export function findByMicrosoftId(microsoftId: string): Promise<UtilisateurAvecRole | null> {
return prisma.utilisateur.findUnique({
where: { microsoftId },
include: { role: true },
});
}
export function findById(id: number): Promise<UtilisateurProfil | null> {
return prisma.utilisateur.findUnique({
where: { id },
@@ -0,0 +1,68 @@
import { env } from '../config/env';
import { AppError } from '../errors/app-error';
import {
findByEmail,
findByMicrosoftId,
UtilisateurAvecRole,
} from '../repositories/utilisateur.repository';
import { AuthenticatedUser } from '../types/authenticated-user';
import { verifyAzureAccessToken } from './azure-token.service';
export interface AuthenticationHeaders {
authorization?: string;
demoEmail?: string;
}
function toAuthenticatedUser(utilisateur: UtilisateurAvecRole): AuthenticatedUser {
return {
id: utilisateur.id,
email: utilisateur.email,
roleCode: utilisateur.role.code,
campusId: utilisateur.campusId,
};
}
function bearerToken(authorization: string | undefined): string {
const match = authorization?.match(/^Bearer\s+(\S+)$/i);
if (!match) {
throw new AppError(401, 'Jeton Bearer requis');
}
return match[1];
}
async function authenticateDemo(email: string | undefined): Promise<UtilisateurAvecRole | null> {
if (!email) {
throw new AppError(401, 'En-tete x-user-email requis en mode demo');
}
return findByEmail(email.trim().toLowerCase());
}
async function authenticateAzure(
authorization: string | undefined,
): Promise<UtilisateurAvecRole | null> {
const identity = await verifyAzureAccessToken(bearerToken(authorization));
const byMicrosoftId = await findByMicrosoftId(identity.microsoftId);
if (byMicrosoftId) {
return byMicrosoftId;
}
return findByEmail(identity.email);
}
export async function authenticateUser(
headers: AuthenticationHeaders,
): Promise<AuthenticatedUser> {
const utilisateur =
env.auth.mode === 'azure'
? await authenticateAzure(headers.authorization)
: await authenticateDemo(headers.demoEmail);
if (!utilisateur || !utilisateur.actif) {
throw new AppError(401, 'Utilisateur non reconnu');
}
return toAuthenticatedUser(utilisateur);
}
@@ -0,0 +1,122 @@
import jwt, {
GetPublicKeyOrSecret,
JwtHeader,
JwtPayload,
SigningKeyCallback,
} from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { env } from '../config/env';
import { AppError } from '../errors/app-error';
export interface AzureIdentity {
microsoftId: string;
email: string;
}
interface AzureClaims extends JwtPayload {
oid?: string;
tid?: string;
scp?: string;
preferred_username?: string;
upn?: string;
email?: string;
}
const issuer = `https://login.microsoftonline.com/${env.auth.azure.tenantId}/v2.0`;
const client = jwksClient({
jwksUri: `${issuer}/discovery/v2.0/keys`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000,
rateLimit: true,
jwksRequestsPerMinute: 10,
timeout: 5000,
});
const getSigningKey: GetPublicKeyOrSecret = (
header: JwtHeader,
callback: SigningKeyCallback,
): void => {
if (!header.kid) {
callback(new Error('Identifiant de cle de signature absent'));
return;
}
client.getSigningKey(header.kid, (error, key) => {
if (error || !key) {
callback(error || new Error('Cle de signature Microsoft introuvable'));
return;
}
callback(null, key.getPublicKey());
});
};
function verifyJwt(token: string): Promise<AzureClaims> {
return new Promise((resolve, reject) => {
jwt.verify(
token,
getSigningKey,
{
algorithms: ['RS256'],
audience: env.auth.azure.audience,
issuer,
},
(error, decoded) => {
if (error || !decoded || typeof decoded === 'string') {
reject(error || new Error('Contenu du jeton invalide'));
return;
}
resolve(decoded);
},
);
});
}
function emailFromClaims(claims: AzureClaims): string | null {
const email = claims.preferred_username || claims.upn || claims.email;
return email?.trim().toLowerCase() || null;
}
function hasAllowedEmailDomain(email: string): boolean {
const separatorIndex = email.lastIndexOf('@');
if (separatorIndex < 1) {
return false;
}
const domain = email.slice(separatorIndex + 1);
return env.auth.azure.allowedEmailDomains.includes(domain);
}
export async function verifyAzureAccessToken(token: string): Promise<AzureIdentity> {
if (env.auth.mode !== 'azure') {
throw new AppError(500, 'Validation Azure indisponible hors du mode Azure');
}
let claims: AzureClaims;
try {
claims = await verifyJwt(token);
} catch {
throw new AppError(401, 'Jeton Microsoft invalide ou expire');
}
if (claims.tid !== env.auth.azure.tenantId) {
throw new AppError(401, 'Tenant Microsoft non autorise');
}
const scopes = claims.scp?.split(' ') || [];
if (!scopes.includes(env.auth.azure.scope)) {
throw new AppError(403, 'Permission Microsoft insuffisante');
}
const email = emailFromClaims(claims);
if (!claims.oid || !email || !hasAllowedEmailDomain(email)) {
throw new AppError(401, 'Identite Microsoft non autorisee');
}
return {
microsoftId: claims.oid,
email,
};
}
+485
View File
@@ -0,0 +1,485 @@
-- Adminer 5.4.2 MS SQL dump
DROP TABLE IF EXISTS [dbo].[_prisma_migrations];
CREATE TABLE [dbo].[_prisma_migrations] (
[id] varchar(36) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[checksum] varchar(64) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[finished_at] datetimeoffset NULL,
[migration_name] nvarchar(250) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[logs] nvarchar-05 COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[rolled_back_at] datetimeoffset NULL,
[started_at] datetimeoffset NOT NULL DEFAULT '(getdate())',
[applied_steps_count] int NOT NULL DEFAULT '((0))',
CONSTRAINT [PK___prisma___3213E83F83C32F3A] PRIMARY KEY ([id])
);
INSERT INTO [dbo].[_prisma_migrations] ([id], [checksum], [finished_at], [migration_name], [logs], [rolled_back_at], [started_at], [applied_steps_count]) VALUES
('0f9bb926-166c-4082-8732-8a12e67b677d', 'c2faa7ef8d703d77e508f037850a7c2b07566b0eed00da21d85e1acfc5738a1e', 'Jun 4 2026 12:07:21:0885370PM', '20260604120720_add_materiel_models', NULL, NULL, 'Jun 4 2026 12:07:21:0042228PM', 1),
('247934d2-3c7e-4798-a551-d3bdc81eb86d', 'fb4d09b02739fd6bf7cea5bfd0aad1bb0fbfcce21a04f1bd91ce300a4c8bbfec', 'Jun 5 2026 08:03:28:7441715AM', '20260605080328_add_transaction_models', NULL, NULL, 'Jun 5 2026 08:03:28:6232902AM', 1),
('3266f9eb-7370-4b00-b749-f0ed39263d07', '2e7b96c74202f25fd3ab0a9438fcc8699059fd5501d66092148daa41252f146b', 'Jun 4 2026 11:52:51:4456229AM', '20260604115251_add_infrastructure_models', NULL, NULL, 'Jun 4 2026 11:52:51:3998600AM', 1),
('3cced054-eb44-41f8-89ac-51a72201310a', '5e82403dcc57c7ab48dea2fe70e688714145f7f46b770a5c79ea868e6ba60f7a', 'Jun 4 2026 10:30:16:9084629AM', '20260604103016_add_defaults_and_timestamps', NULL, NULL, 'Jun 4 2026 10:30:16:8610742AM', 1),
('3e9709da-458c-402d-8b32-bac51a84d241', 'c95d2b3e6c2454daa3fb9ba2ef7a99f9419e991174869612c81ab907e5a6d802', 'Jun 4 2026 12:02:27:3861796PM', '20260604120227_add_carte_etudiante', NULL, NULL, 'Jun 4 2026 12:02:27:3195110PM', 1),
('5b298865-5e96-4b54-ad96-1ec3acc7080a', '5e3e0d880e67c5ba91ccdb04f0dee6c056da6a325510c9dd9e4f9a80c5625fc4', 'Jun 4 2026 10:54:28:1304306AM', '20260604125356_add_unique_constraints_and_nullable_classe', NULL, NULL, 'Jun 4 2026 10:54:28:0949933AM', 1),
('66581e38-0e42-435a-9937-06479116e595', '4e632e5273ea555527c4726688865c7b21bc03d92df67e2f4679c6428d8a33e2', 'Jun 2 2026 02:19:19:8915005PM', '20260602141919_init', NULL, NULL, 'Jun 2 2026 02:19:19:8389748PM', 1),
('fbf572ea-075e-4077-9d58-39fdf613d737', '29faca22003505a071ea6b845d527566c51086c6b17980f386b70abff64d9148', 'Jun 15 2026 07:57:00:9671170AM', '20260615075700_add_suivi_models', NULL, NULL, 'Jun 15 2026 07:57:00:8308160AM', 1);
DROP TABLE IF EXISTS [dbo].[accessoire];
CREATE TABLE [dbo].[accessoire] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[accessoire] ON;
INSERT INTO [dbo].[accessoire] ([id], [nom], [description], [actif], [created_at], [updated_at]) VALUES
(1, 'Chargeur', 'Chargeur secteur.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 'Souris', 'Souris filaire.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 'Housse', 'Housse de protection.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(1002, 'Chargeur USB-C', 'Accessoire Chargeur USB-C.', '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1003, 'Clavier Surface', 'Accessoire Clavier Surface.', '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1004, 'Stylet', 'Accessoire Stylet.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1005, 'Telecommande', 'Accessoire Telecommande.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1006, 'Cable HDMI', 'Accessoire Cable HDMI.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1007, 'Batterie', 'Accessoire Batterie.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1008, 'Carte SD', 'Accessoire Carte SD.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1009, 'Adaptateur USB-C', 'Accessoire Adaptateur USB-C.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1010, 'Cable USB-C', 'Accessoire Cable USB-C.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39');
SET IDENTITY_INSERT [dbo].[accessoire] OFF;
DROP TABLE IF EXISTS [dbo].[anomalie];
CREATE TABLE [dbo].[anomalie] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[emprunt_id] int NOT NULL,
[materiel_id] int NOT NULL,
[utilisateur_id] int NOT NULL,
[traitee_par_id] int NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[detectee_automatiquement] bit NOT NULL DEFAULT '((1))',
[observation] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[date_detection] datetime2 NOT NULL,
[date_resolution] datetime2 NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[anomalie] ON;
INSERT INTO [dbo].[anomalie] ([id], [emprunt_id], [materiel_id], [utilisateur_id], [traitee_par_id], [type], [description], [statut], [detectee_automatiquement], [observation], [date_detection], [date_resolution], [created_at], [updated_at]) VALUES
(1, 4, 4, 3, NULL, 'ACCESSOIRE_MANQUANT', 'Housse declaree presente au depart, absente au retour.', 'DETECTEE', '1', NULL, '2026-06-14 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 1002, 3, 1, NULL, 'RETOUR_NON_CONFORME', 'Retour non conforme : Etat general manquant au retour.', 'DETECTEE', '1', NULL, '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1002, 4003, 1009, 2, NULL, 'RETOUR_NON_CONFORME', 'Retour non conforme : Cable USB-C manquant au retour.', 'DETECTEE', '1', NULL, '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[anomalie] OFF;
DROP TABLE IF EXISTS [dbo].[campus];
CREATE TABLE [dbo].[campus] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[adresse] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[ville] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[code_postal] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[campus] ON;
INSERT INTO [dbo].[campus] ([id], [nom], [adresse], [ville], [code_postal], [actif], [created_at], [updated_at]) VALUES
(1, 'Campus Saint-Christophe', 'À compléter — Cergy Saint-Christophe', 'Cergy', '95800', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[campus] OFF;
DROP TABLE IF EXISTS [dbo].[carte_etudiante];
CREATE TABLE [dbo].[carte_etudiante] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[qr_code] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_activation] datetime2 NOT NULL,
[date_expiration] datetime2 NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[carte_etudiante] ON;
INSERT INTO [dbo].[carte_etudiante] ([id], [utilisateur_id], [qr_code], [date_activation], [date_expiration], [actif], [created_at], [updated_at]) VALUES
(1, 1, 'QR-ETU-0001', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 2, 'QR-ETU-0002', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 3, 'QR-ETU-0003', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52');
SET IDENTITY_INSERT [dbo].[carte_etudiante] OFF;
DROP TABLE IF EXISTS [dbo].[categorie_materiel];
CREATE TABLE [dbo].[categorie_materiel] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[categorie_materiel] ON;
INSERT INTO [dbo].[categorie_materiel] ([id], [nom], [description], [actif], [created_at], [updated_at]) VALUES
(1, 'Ordinateur portable', 'Ordinateurs portables pédagogiques.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(2, 'Vidéoprojecteur', 'Vidéoprojecteurs et accessoires de projection.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(3, 'Tablette', 'Tablettes tactiles.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(4, 'Caméra', 'Caméras et matériel de captation vidéo.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(5, 'Périphérique audio', 'Micros, casques et enceintes.', '1', '2026-06-15 09:03:38', '2026-06-15 09:03:38'),
(6, 'Câble / Adaptateur', 'Câbles, adaptateurs et connectique.', '1', '2026-06-15 09:03:38', '2026-06-15 09:03:38');
SET IDENTITY_INSERT [dbo].[categorie_materiel] OFF;
DROP TABLE IF EXISTS [dbo].[checklist];
CREATE TABLE [dbo].[checklist] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[emprunt_id] int NOT NULL,
[utilisateur_id] int NOT NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_verification] datetime2 NOT NULL,
[commentaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[checklist] ON;
INSERT INTO [dbo].[checklist] ([id], [emprunt_id], [utilisateur_id], [type], [date_verification], [commentaire], [created_at], [updated_at]) VALUES
(1, 1, 1, 'DEPART', '2026-06-13 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 2, 2, 'DEPART', '2026-05-26 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(3, 3, 1, 'DEPART', '2026-06-05 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 3, 1, 'RETOUR', '2026-06-12 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(5, 4, 3, 'DEPART', '2026-06-10 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(6, 4, 3, 'RETOUR', '2026-06-14 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1002, 1, 'DEPART', '2026-07-06 07:38:13', NULL, '2026-07-06 07:38:13', '2026-07-06 07:38:13'),
(1003, 1, 1, 'RETOUR', '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1004, 1002, 1, 'RETOUR', '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(2002, 2002, 2, 'DEPART', '2026-07-07 11:51:13', NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2003, 2003, 2, 'DEPART', '2026-07-07 11:58:06', NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(3002, 2, 2, 'RETOUR', '2026-07-07 12:33:18', NULL, '2026-07-07 12:33:18', '2026-07-07 12:33:18'),
(3003, 2002, 2, 'RETOUR', '2026-07-07 12:33:29', NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3004, 2003, 2, 'RETOUR', '2026-07-07 12:33:43', NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3005, 3002, 2, 'DEPART', '2026-07-07 12:34:25', NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 4002, 2, 'DEPART', '2026-07-08 09:19:17', NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 4002, 2, 'RETOUR', '2026-07-08 09:19:17', NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4004, 4003, 2, 'DEPART', '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4005, 4003, 2, 'RETOUR', '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4006, 4004, 2, 'DEPART', '2026-07-08 13:19:04', NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[checklist] OFF;
DROP TABLE IF EXISTS [dbo].[checklist_element];
CREATE TABLE [dbo].[checklist_element] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[checklist_id] int NOT NULL,
[accessoire_id] int NULL,
[nom_element] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[etat] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[quantite_constatee] int NOT NULL,
[commentaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[checklist_element] ON;
INSERT INTO [dbo].[checklist_element] ([id], [checklist_id], [accessoire_id], [nom_element], [etat], [quantite_constatee], [commentaire], [created_at], [updated_at]) VALUES
(1, 1, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 1, 2, 'Souris', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(3, 5, 3, 'Housse', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 5, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(5, 6, 3, 'Housse', 'ABSENT', 0, 'Manquante au retour', '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(6, 6, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1002, NULL, 'Etat general', 'PRESENT', 1, NULL, '2026-07-06 07:38:13', '2026-07-06 07:38:13'),
(1003, 1003, NULL, 'Chargeur', 'PRESENT', 1, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1004, 1003, NULL, 'Souris', 'PRESENT', 1, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1005, 1004, NULL, 'Etat general', 'ABSENT', 0, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(2002, 2002, NULL, 'Batterie', 'ABSENT', 0, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2003, 2002, NULL, 'Carte SD', 'PRESENT', 1, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2004, 2002, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2005, 2003, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(2006, 2003, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(3002, 3002, NULL, 'Vidéoprojecteur Epson', 'PRESENT', 1, NULL, '2026-07-07 12:33:18', '2026-07-07 12:33:18'),
(3003, 3003, NULL, 'Batterie', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3004, 3003, NULL, 'Carte SD', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3005, 3003, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3006, 3004, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3007, 3004, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3008, 3005, NULL, 'Chargeur', 'PRESENT', 1, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(3009, 3005, NULL, 'Housse', 'ABSENT', 0, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(3010, 3005, NULL, 'Souris', 'PRESENT', 1, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 4002, NULL, 'Casque Jabra Evolve', 'PRESENT', 1, NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 4003, NULL, 'Casque Jabra Evolve', 'PRESENT', 1, NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4004, 4004, NULL, 'Cable USB-C', 'PRESENT', 1, NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4005, 4005, NULL, 'Cable USB-C', 'ABSENT', 0, NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4006, 4006, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04'),
(4007, 4006, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[checklist_element] OFF;
DROP TABLE IF EXISTS [dbo].[emprunt];
CREATE TABLE [dbo].[emprunt] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[materiel_id] int NOT NULL,
[campus_id] int NOT NULL,
[salle_pret_id] int NOT NULL,
[poste_emprunt_id] int NOT NULL,
[date_emprunt] datetime2 NOT NULL,
[date_retour_prevue] datetime2 NOT NULL,
[date_retour_reelle] datetime2 NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[mode_identification_emprunt] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[mode_identification_retour] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[commentaire_depart] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[commentaire_retour] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[emprunt] ON;
INSERT INTO [dbo].[emprunt] ([id], [utilisateur_id], [materiel_id], [campus_id], [salle_pret_id], [poste_emprunt_id], [date_emprunt], [date_retour_prevue], [date_retour_reelle], [statut], [mode_identification_emprunt], [mode_identification_retour], [commentaire_depart], [commentaire_retour], [created_at], [updated_at]) VALUES
(1, 1, 1, 1, 1, 1, '2026-06-13 14:48:51', '2026-06-20 14:48:51', '2026-07-06 08:03:20', 'CLOTURE', 'QR_CODE', 'QR_CODE', NULL, NULL, '2026-06-15 14:48:53', '2026-07-06 08:03:20'),
(2, 2, 2, 1, 1, 1, '2026-05-26 14:48:51', '2026-06-09 14:48:51', '2026-07-07 12:33:18', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-06-15 14:48:53', '2026-07-07 12:33:18'),
(3, 1, 3, 1, 1, 1, '2026-06-05 14:48:51', '2026-06-12 14:48:51', '2026-06-12 14:48:51', 'CLOTURE', 'QR_CODE', 'QR_CODE', NULL, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 3, 4, 1, 1, 1, '2026-06-10 14:48:51', '2026-06-14 14:48:51', '2026-06-14 14:48:51', 'RETOUR_NON_CONFORME', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, 'Housse non rapportee.', '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1, 3, 1, 1, 1, '2026-07-06 07:38:13', '2026-07-20 07:38:13', '2026-07-06 08:03:20', 'RETOUR_NON_CONFORME', 'QR_CODE', 'QR_CODE', 'RAS', 'element manquant', '2026-07-06 07:38:13', '2026-07-06 08:03:20'),
(2002, 2, 1006, 1, 1, 1, '2026-07-07 11:51:13', '2026-07-21 11:51:13', '2026-07-07 12:33:29', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-07-07 11:51:13', '2026-07-07 12:33:29'),
(2003, 2, 1003, 1, 1, 1, '2026-07-07 11:58:06', '2026-07-21 11:58:06', '2026-07-07 12:33:43', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-07-07 11:58:06', '2026-07-07 12:33:44'),
(3002, 2, 1002, 1, 1, 1, '2026-07-07 12:34:25', '2026-07-21 12:34:25', NULL, 'EN_COURS', 'MAIL_ENSUP', NULL, NULL, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 2, 1007, 1, 1, 1, '2026-07-08 09:19:17', '2026-07-22 09:19:17', '2026-07-08 09:19:17', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', 'Test stabilisation API', 'Retour test conforme', '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 2, 1009, 1, 1, 1, '2026-07-08 09:19:47', '2026-07-22 09:19:46', '2026-07-08 09:19:47', 'RETOUR_NON_CONFORME', 'MAIL_ENSUP', 'MAIL_ENSUP', 'Test non conforme API', 'Retour test non conforme', '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4004, 2, 1003, 1, 1, 1, '2026-07-08 13:19:04', '2026-07-22 13:19:04', NULL, 'EN_COURS', 'MAIL_ENSUP', NULL, NULL, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[emprunt] OFF;
DROP TABLE IF EXISTS [dbo].[historique];
CREATE TABLE [dbo].[historique] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[emprunt_id] int NULL,
[materiel_id] int NULL,
[campus_id] int NULL,
[salle_pret_id] int NULL,
[poste_emprunt_id] int NULL,
[action] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_action] datetime2 NOT NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())'
);
SET IDENTITY_INSERT [dbo].[historique] ON;
INSERT INTO [dbo].[historique] ([id], [utilisateur_id], [emprunt_id], [materiel_id], [campus_id], [salle_pret_id], [poste_emprunt_id], [action], [description], [date_action], [created_at]) VALUES
(1, 1, 1, 1, 1, 1, 1, 'CREATION_EMPRUNT', 'Emprunt du PC portable Dell.', '2026-06-13 14:48:51', '2026-06-15 14:48:53'),
(2, 3, 4, 4, 1, 1, 1, 'CREATION_ANOMALIE', 'Anomalie detectee au retour de la Caméra Sony.', '2026-06-14 14:48:51', '2026-06-15 14:48:53');
SET IDENTITY_INSERT [dbo].[historique] OFF;
DROP TABLE IF EXISTS [dbo].[materiel];
CREATE TABLE [dbo].[materiel] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[marque] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[modele] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[reference] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[numero_inventaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[numero_serie] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[etat_general] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[categorie_id] int NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[materiel] ON;
INSERT INTO [dbo].[materiel] ([id], [nom], [marque], [modele], [reference], [numero_inventaire], [numero_serie], [statut], [etat_general], [categorie_id], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'PC portable Dell Latitude', 'Dell', 'Latitude 5440', 'REF-PC-001', 'INV-PC-001', 'SN-DELL-001', 'DISPONIBLE', 'Bon etat', 1, 1, '1', '2026-06-15 14:48:52', '2026-07-06 08:03:20'),
(2, 'Vidéoprojecteur Epson', 'Epson', 'EB-W06', 'REF-VP-001', 'INV-VP-001', 'SN-EPSON-001', 'DISPONIBLE', 'Bon etat', 2, 1, '1', '2026-06-15 14:48:52', '2026-07-07 12:33:18'),
(3, 'Tablette iPad', 'Apple', 'iPad 10', 'REF-TAB-001', 'INV-TAB-001', 'SN-IPAD-001', 'NON_CONFORME', 'Bon etat', 3, 1, '1', '2026-06-15 14:48:52', '2026-07-06 08:03:20'),
(4, 'Caméra Sony', 'Sony', 'Alpha 6400', 'REF-CAM-001', 'INV-CAM-001', 'SN-SONY-001', 'DETERIORE', 'Housse manquante au retour', 4, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(5, 'Micro Rode', 'Rode', 'NT-USB', 'REF-MIC-001', 'INV-MIC-001', 'SN-RODE-001', 'DISPONIBLE', 'Bon etat', 5, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(6, 'PC portable HP', 'HP', 'ProBook 450', 'REF-PC-002', 'INV-PC-002', 'SN-HP-002', 'MAINTENANCE', 'En revision', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(1002, 'PC Lenovo ThinkPad E14', 'Lenovo', 'ThinkPad E14', 'REF-PC-003', 'INV-PC-003', 'SN-LENOVO-003', 'EMPRUNTE', 'Bon etat', 1, 1, '1', '2026-07-07 11:39:38', '2026-07-07 12:34:25'),
(1003, 'MacBook Air M2', 'Apple', 'MacBook Air 13', 'REF-PC-004', 'INV-PC-004', 'SN-MBA-004', 'EMPRUNTE', 'Tres bon etat', 1, 1, '1', '2026-07-07 11:39:38', '2026-07-08 13:19:04'),
(1004, 'Microsoft Surface Pro', 'Microsoft', 'Surface Pro 9', 'REF-TAB-002', 'INV-TAB-002', 'SN-SURFACE-002', 'DISPONIBLE', 'Bon etat', 3, 1, '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1005, 'Vidéoprojecteur Epson EB-X49', 'Epson', 'EB-X49', 'REF-VP-002', 'INV-VP-002', 'SN-EPSON-002', 'DISPONIBLE', 'Bon etat', 2, 1, '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1006, 'Caméra Canon EOS M50', 'Canon', 'EOS M50', 'REF-CAM-002', 'INV-CAM-002', 'SN-CANON-002', 'DISPONIBLE', 'Bon etat', 4, 1, '1', '2026-07-07 11:39:39', '2026-07-07 12:33:29'),
(1007, 'Casque Jabra Evolve', 'Jabra', 'Evolve 40', 'REF-AUD-002', 'INV-AUD-002', 'SN-JABRA-002', 'DISPONIBLE', 'Bon etat', 5, 1, '1', '2026-07-07 11:39:39', '2026-07-08 09:19:17'),
(1008, 'Kit câbles HDMI / USB-C', 'ENSUP', 'Kit connectique', 'REF-CAB-001', 'INV-CAB-001', 'SN-CABLE-001', 'DISPONIBLE', 'Bon etat', 6, 1, '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1009, 'Enceinte Bluetooth JBL', 'JBL', 'Flip 6', 'REF-AUD-003', 'INV-AUD-003', 'SN-JBL-003', 'NON_CONFORME', 'Bon etat', 5, 1, '1', '2026-07-07 11:39:39', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[materiel] OFF;
DROP TABLE IF EXISTS [dbo].[materiel_accessoire];
CREATE TABLE [dbo].[materiel_accessoire] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[materiel_id] int NOT NULL,
[accessoire_id] int NOT NULL,
[quantite_attendue] int NOT NULL,
[obligatoire] bit NOT NULL
);
SET IDENTITY_INSERT [dbo].[materiel_accessoire] ON;
INSERT INTO [dbo].[materiel_accessoire] ([id], [materiel_id], [accessoire_id], [quantite_attendue], [obligatoire]) VALUES
(1, 1, 1, 1, '1'),
(2, 1, 2, 1, '0'),
(3, 4, 3, 1, '1'),
(4, 4, 1, 1, '1'),
(1002, 1002, 1, 1, '1'),
(1003, 1002, 2, 1, '1'),
(1004, 1002, 3, 1, '1'),
(1005, 1003, 1002, 1, '1'),
(1006, 1003, 3, 1, '1'),
(1007, 1004, 1003, 1, '1'),
(1008, 1004, 1004, 1, '1'),
(1009, 1005, 1005, 1, '1'),
(1010, 1005, 1006, 1, '1'),
(1011, 1006, 1007, 1, '1'),
(1012, 1006, 1008, 1, '1'),
(1013, 1006, 3, 1, '1'),
(1014, 1008, 1006, 1, '1'),
(1015, 1008, 1009, 1, '1'),
(1016, 1009, 1010, 1, '1');
SET IDENTITY_INSERT [dbo].[materiel_accessoire] OFF;
DROP TABLE IF EXISTS [dbo].[notification];
CREATE TABLE [dbo].[notification] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[anomalie_id] int NULL,
[titre] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[message] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[lu] bit NOT NULL DEFAULT '((0))',
[date_creation] datetime2 NOT NULL DEFAULT '(getdate())'
);
SET IDENTITY_INSERT [dbo].[notification] ON;
INSERT INTO [dbo].[notification] ([id], [utilisateur_id], [anomalie_id], [titre], [message], [type], [lu], [date_creation]) VALUES
(1, 4, 1, 'Nouvelle anomalie detectee', 'Retour non conforme sur la Caméra Sony (housse manquante).', 'ANOMALIE', '0', '2026-06-15 14:48:53'),
(2, 4, 2, 'Nouvelle anomalie detectee', 'Retour non conforme : Etat general manquant au retour.', 'ANOMALIE', '0', '2026-07-06 08:03:20'),
(1002, 4, 1002, 'Nouvelle anomalie detectee', 'Retour non conforme : Cable USB-C manquant au retour.', 'ANOMALIE', '0', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[notification] OFF;
DROP TABLE IF EXISTS [dbo].[poste_emprunt];
CREATE TABLE [dbo].[poste_emprunt] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[identifiant_poste] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[salle_pret_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[poste_emprunt] ON;
INSERT INTO [dbo].[poste_emprunt] ([id], [nom], [identifiant_poste], [salle_pret_id], [actif], [created_at], [updated_at]) VALUES
(1, 'Poste 1', 'POSTE-01', 1, '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[poste_emprunt] OFF;
DROP TABLE IF EXISTS [dbo].[role];
CREATE TABLE [dbo].[role] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[code] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[libelle] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
CONSTRAINT [role_code_key] UNIQUE ([code])
);
SET IDENTITY_INSERT [dbo].[role] ON;
INSERT INTO [dbo].[role] ([id], [code], [libelle], [description]) VALUES
(1, 'ETUDIANT', 'Étudiant', 'Emprunte et restitue du matériel via le poste dédié.'),
(2, 'RESPONSABLE', 'Responsable matériel', 'Supervise, gère le stock et traite les anomalies.');
SET IDENTITY_INSERT [dbo].[role] OFF;
DROP TABLE IF EXISTS [dbo].[salle_pret];
CREATE TABLE [dbo].[salle_pret] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[localisation] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[salle_pret] ON;
INSERT INTO [dbo].[salle_pret] ([id], [nom], [localisation], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'Salle de prêt principale', 'Campus Saint-Christophe', 1, '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[salle_pret] OFF;
DROP TABLE IF EXISTS [dbo].[utilisateur];
CREATE TABLE [dbo].[utilisateur] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[microsoft_id] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[prenom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[email] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[classe] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[role_id] int NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL,
CONSTRAINT [utilisateur_microsoft_id_key] UNIQUE ([microsoft_id]),
CONSTRAINT [utilisateur_email_key] UNIQUE ([email])
);
SET IDENTITY_INSERT [dbo].[utilisateur] ON;
INSERT INTO [dbo].[utilisateur] ([id], [microsoft_id], [nom], [prenom], [email], [classe], [role_id], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'ms-marie-dupont', 'Dupont', 'Marie', 'marie.dupont@ensitech.eu', 'B3 Informatique', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 'ms-lucas-martin', 'Martin', 'Lucas', 'lucas.martin@ensitech.eu', 'B2 Informatique', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 'ms-sofia-bernard', 'Bernard', 'Sofia', 'sofia.bernard@ensup.eu', 'B1 Marketing', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(4, 'ms-karim-benali', 'Benali', 'Karim', 'karim.benali@ensup.eu', NULL, 2, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52');
SET IDENTITY_INSERT [dbo].[utilisateur] OFF;
ALTER TABLE [dbo].[anomalie] ADD
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([traitee_par_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[carte_etudiante] ADD
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[checklist] ADD
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[checklist_element] ADD
FOREIGN KEY ([accessoire_id]) REFERENCES [accessoire] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([checklist_id]) REFERENCES [checklist] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[emprunt] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([poste_emprunt_id]) REFERENCES [poste_emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[historique] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([poste_emprunt_id]) REFERENCES [poste_emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[materiel] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([categorie_id]) REFERENCES [categorie_materiel] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[materiel_accessoire] ADD
FOREIGN KEY ([accessoire_id]) REFERENCES [accessoire] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[notification] ADD
FOREIGN KEY ([anomalie_id]) REFERENCES [anomalie] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[poste_emprunt] ADD
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[salle_pret] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[utilisateur] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([role_id]) REFERENCES [role] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
-- 2026-07-10 09:04:02 UTC
+13 -5
View File
@@ -1,10 +1,18 @@
/// Identité simulée utilisée tant que l'authentification Azure AD n'est pas branchée.
import "services/auth_service.dart";
/// Identité de secours utilisée lorsque aucun profil de session n'est chargé.
class DemoIdentity {
DemoIdentity._();
static const userName = "Lucas Martin";
static const userInitials = "LM";
static const userSubtitle = "Étudiant · B2 Informatique · Ensitech Cergy";
static const campusTag = "Saint-Christophe · Cergy";
static String get userName =>
AuthService.currentProfile?.fullName ?? "Lucas Martin";
static String get userInitials =>
AuthService.currentProfile?.initials ?? "LM";
static String get userSubtitle =>
AuthService.currentProfile?.subtitle ??
"Étudiant · B2 Informatique · Ensitech Cergy";
static String get campusTag =>
AuthService.currentProfile?.campusTag ?? "Saint-Christophe · Cergy";
static const loginCampusTag = "Ensitech · Cergy";
}
@@ -0,0 +1,90 @@
class AuthenticatedProfile {
const AuthenticatedProfile({
required this.id,
required this.nom,
required this.prenom,
required this.email,
required this.classe,
required this.roleCode,
required this.roleLabel,
required this.campusId,
required this.campusNom,
required this.campusVille,
});
final int id;
final String nom;
final String prenom;
final String email;
final String? classe;
final String roleCode;
final String roleLabel;
final int campusId;
final String campusNom;
final String campusVille;
factory AuthenticatedProfile.fromApi(dynamic reponse) {
if (reponse is! Map || reponse["data"] is! Map) {
throw const FormatException("Profil utilisateur invalide.");
}
final data = reponse["data"] as Map;
final role = data["role"];
final campus = data["campus"];
if (role is! Map || campus is! Map) {
throw const FormatException("Role ou campus utilisateur invalide.");
}
final id = data["id"];
final campusId = campus["id"];
if (id is! int || campusId is! int) {
throw const FormatException("Identifiant utilisateur invalide.");
}
final classe = data["classe"];
if (classe != null && classe is! String) {
throw const FormatException("Classe utilisateur invalide.");
}
return AuthenticatedProfile(
id: id,
nom: _requiredString(data, "nom"),
prenom: _requiredString(data, "prenom"),
email: _requiredString(data, "email"),
classe: classe as String?,
roleCode: _requiredString(role, "code"),
roleLabel: _requiredString(role, "libelle"),
campusId: campusId,
campusNom: _requiredString(campus, "nom"),
campusVille: _requiredString(campus, "ville"),
);
}
String get fullName => "$prenom $nom";
String get initials {
final first = prenom.trim().isEmpty ? "" : prenom.trim()[0];
final last = nom.trim().isEmpty ? "" : nom.trim()[0];
return "$first$last".toUpperCase();
}
String get campusTag => "$campusNom · $campusVille";
String get subtitle {
final details = <String>[roleLabel];
final studentClass = classe?.trim();
if (studentClass != null && studentClass.isNotEmpty) {
details.add(studentClass);
}
details.add(campusTag);
return details.join(" · ");
}
static String _requiredString(Map source, String key) {
final value = source[key];
if (value is! String || value.trim().isEmpty) {
throw FormatException("Champ utilisateur invalide : $key.");
}
return value.trim();
}
}
@@ -23,7 +23,8 @@ class ConfirmationScreen extends StatelessWidget {
final dateEmprunt = emprunt.dateEmprunt;
final date =
"${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}";
final heure = "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
final heure =
"${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
return Scaffold(
backgroundColor: EnsupColors.soft,
@@ -48,7 +49,7 @@ class ConfirmationScreen extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Column(
children: [
const EnsupTopBar(
EnsupTopBar(
brandText: "EME · Confirmation",
campusTag: DemoIdentity.campusTag,
),
@@ -63,7 +64,10 @@ class ConfirmationScreen extends StatelessWidget {
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 32,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
@@ -75,10 +79,15 @@ class ConfirmationScreen extends StatelessWidget {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => Navigator.of(context).popUntil(
(route) => route.settings.name == "home",
onPressed: () =>
Navigator.of(context).popUntil(
(route) =>
route.settings.name == "home",
),
icon: const Icon(
Icons.home_outlined,
size: 18,
),
icon: const Icon(Icons.home_outlined, size: 18),
label: Text(
"Retour à l'accueil",
style: GoogleFonts.darkerGrotesque(
@@ -88,9 +97,13 @@ class ConfirmationScreen extends StatelessWidget {
),
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.symmetric(
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(
10,
),
),
),
),
@@ -118,7 +131,9 @@ class ConfirmationScreen extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.all(36),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]),
gradient: const LinearGradient(
colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
),
@@ -129,7 +144,9 @@ class ConfirmationScreen extends StatelessWidget {
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]),
gradient: const LinearGradient(
colors: [Color(0xFF16A34A), Color(0xFF22C55E)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF16A34A).withValues(alpha: 0.3),
@@ -153,7 +170,10 @@ class ConfirmationScreen extends StatelessWidget {
Text(
"Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)),
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: const Color(0xFF166534),
),
),
],
),
@@ -191,7 +211,8 @@ class ConfirmationScreen extends StatelessWidget {
_rrow("Date d'emprunt", date),
_rrow("Heure", heure),
_rrow("Campus", item.campus),
if (note != null) _rrow("Note", note, valueColor: const Color(0xFFD97706)),
if (note != null)
_rrow("Note", note, valueColor: const Color(0xFFD97706)),
],
),
);
@@ -203,7 +224,13 @@ class ConfirmationScreen extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
Text(
cle,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(width: 16),
Expanded(
child: Text(
+17 -2
View File
@@ -1,6 +1,7 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../services/auth_service.dart";
import "../theme/ensup_colors.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/profile_card.dart";
@@ -13,6 +14,20 @@ import "restitution_select_screen.dart";
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Future<void> _quitter(BuildContext context) async {
try {
await AuthService.signOut();
if (context.mounted) {
Navigator.of(context).pop();
}
} catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -42,7 +57,7 @@ class HomeScreen extends StatelessWidget {
campusTag: DemoIdentity.campusTag,
userName: DemoIdentity.userName,
userInitials: DemoIdentity.userInitials,
onBack: () => Navigator.of(context).pop(),
onBack: () async => _quitter(context),
),
Expanded(
child: SingleChildScrollView(
@@ -56,7 +71,7 @@ class HomeScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ProfileCard(
ProfileCard(
name: DemoIdentity.userName,
subtitle: DemoIdentity.userSubtitle,
initials: DemoIdentity.userInitials,
+124 -13
View File
@@ -1,6 +1,8 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../services/api_client.dart";
import "../services/auth_service.dart";
import "../theme/ensup_colors.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/identification_option.dart";
@@ -9,10 +11,25 @@ import "home_screen.dart";
import "responsable_screen.dart";
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
class LoginScreen extends StatelessWidget {
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
void _identifier(BuildContext context) {
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
bool _connexionEnCours = false;
@override
void initState() {
super.initState();
if (AuthService.mode == AuthenticationMode.azure) {
WidgetsBinding.instance.addPostFrameCallback((_) => _restaurerSession());
}
}
void _ouvrirEtudiant() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const HomeScreen(),
@@ -21,6 +38,99 @@ class LoginScreen extends StatelessWidget {
);
}
void _ouvrirResponsable() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const ResponsableScreen(),
settings: const RouteSettings(name: "responsable"),
),
);
}
void _ouvrirPourRole(String roleCode) {
switch (roleCode) {
case "ETUDIANT":
_ouvrirEtudiant();
case "RESPONSABLE":
_ouvrirResponsable();
default:
throw StateError("Role utilisateur non autorise.");
}
}
Future<void> _restaurerSession() async {
setState(() => _connexionEnCours = true);
try {
if (!await AuthService.hasMicrosoftAccount()) {
return;
}
final reponse = await ApiClient.get("/auth/me");
final profile = AuthService.setProfileFromApi(reponse);
if (mounted) {
_ouvrirPourRole(profile.roleCode);
}
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("La session Microsoft doit etre renouvelee."),
),
);
} finally {
if (mounted) {
setState(() => _connexionEnCours = false);
}
}
}
Future<void> _identifierParQr() async {
if (_connexionEnCours) return;
if (AuthService.mode == AuthenticationMode.azure) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"L'identification QR securisee n'est pas encore configuree.",
),
),
);
return;
}
await _connecterMicrosoft(responsableDemo: false);
}
Future<void> _connecterMicrosoft({required bool responsableDemo}) async {
if (_connexionEnCours) return;
setState(() => _connexionEnCours = true);
try {
if (AuthService.mode == AuthenticationMode.demo) {
if (responsableDemo) {
AuthService.selectDemoResponsable();
} else {
AuthService.selectDemoStudent();
}
} else {
await AuthService.signInWithMicrosoft();
}
final reponse = await ApiClient.get("/auth/me");
final profile = AuthService.setProfileFromApi(reponse);
if (!mounted) return;
_ouvrirPourRole(profile.roleCode);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _connexionEnCours = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -122,7 +232,9 @@ class LoginScreen extends StatelessWidget {
label: "Scanner ma carte étudiante",
description:
"Présentez votre carte étudiante dématérialisée à la douchette ou à la tablette",
onTap: () => _identifier(context),
onTap: _connexionEnCours
? null
: _identifierParQr,
),
const SizedBox(height: 12),
IdentificationOption(
@@ -132,7 +244,11 @@ class LoginScreen extends StatelessWidget {
"Connexion compte professionnel ENSUP",
description:
"Utiliser mon adresse e-mail Microsoft 365",
onTap: () => _identifier(context),
onTap: _connexionEnCours
? null
: () => _connecterMicrosoft(
responsableDemo: false,
),
),
const SizedBox(height: 20),
Row(
@@ -161,15 +277,10 @@ class LoginScreen extends StatelessWidget {
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () =>
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
const ResponsableScreen(),
settings: const RouteSettings(
name: "responsable",
),
),
onPressed: _connexionEnCours
? null
: () => _connecterMicrosoft(
responsableDemo: true,
),
icon: const Icon(
Icons.person_outline,
@@ -1,7 +1,7 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../services/auth_service.dart";
import "../services/responsable_service.dart";
import "../theme/ensup_colors.dart";
import "../utils/file_download.dart";
@@ -44,6 +44,20 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
setState(() => _future = _charger());
}
Future<void> _quitter() async {
try {
await AuthService.signOut();
if (mounted) {
Navigator.of(context).pop();
}
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
Future<void> _changerStatut(Map<String, dynamic> anomalie) async {
final prochain = _prochainStatut(anomalie["statut"] as String?);
if (prochain == null || _transitionEnCours) {
@@ -124,10 +138,7 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
body: SafeArea(
child: Column(
children: [
_ResponsableTopBar(
onExit: () => Navigator.of(context).pop(),
onRefresh: _rafraichir,
),
_ResponsableTopBar(onExit: _quitter, onRefresh: _rafraichir),
Expanded(
child: FutureBuilder<_ResponsableData>(
future: _future,
@@ -249,7 +260,7 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
anomaliesActives: anomaliesActives,
notificationsNonLues: notificationsNonLues,
onChanged: (index) => setState(() => _onglet = index),
onExit: () => Navigator.of(context).pop(),
onExit: _quitter,
),
Expanded(
child: Column(
@@ -451,6 +462,12 @@ class _ResponsableTopBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final profile = AuthService.currentProfile;
final campusTag = profile?.campusTag ?? "Ensitech · Cergy";
final initials = profile?.initials ?? "KB";
final userName = profile?.fullName ?? "Karim Benali";
final roleLabel = profile?.roleLabel ?? "Responsable matériel";
return Container(
height: 68,
padding: const EdgeInsets.symmetric(horizontal: 20),
@@ -521,7 +538,7 @@ class _ResponsableTopBar extends StatelessWidget {
),
const SizedBox(width: 6),
Text(
DemoIdentity.loginCampusTag,
campusTag,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -553,7 +570,7 @@ class _ResponsableTopBar extends StatelessWidget {
shape: BoxShape.circle,
),
child: Text(
"KB",
initials,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontSize: 13,
@@ -568,7 +585,7 @@ class _ResponsableTopBar extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Karim Benali",
userName,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w700,
@@ -576,7 +593,7 @@ class _ResponsableTopBar extends StatelessWidget {
),
),
Text(
"Responsable matériel",
roleLabel,
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: EnsupColors.muted,
@@ -10,7 +10,11 @@ import "../widgets/brand_corners.dart";
/// Résultat de la restitution (RG17-RG20) : conforme (succès) ou non conforme
/// (anomalie détectée + notification au responsable).
class ResultatRetourScreen extends StatelessWidget {
const ResultatRetourScreen({super.key, required this.emprunt, required this.anomalies});
const ResultatRetourScreen({
super.key,
required this.emprunt,
required this.anomalies,
});
final EmpruntItem emprunt;
final List<AnomalieRetour> anomalies;
@@ -48,7 +52,7 @@ class ResultatRetourScreen extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: Column(
children: [
const EnsupTopBar(
EnsupTopBar(
brandText: "EME · Résultat du contrôle",
campusTag: DemoIdentity.campusTag,
),
@@ -63,7 +67,10 @@ class ResultatRetourScreen extends StatelessWidget {
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 32,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
@@ -73,10 +80,15 @@ class ResultatRetourScreen extends StatelessWidget {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => Navigator.of(context).popUntil(
(route) => route.settings.name == "home",
onPressed: () =>
Navigator.of(context).popUntil(
(route) =>
route.settings.name == "home",
),
icon: const Icon(
Icons.home_outlined,
size: 18,
),
icon: const Icon(Icons.home_outlined, size: 18),
label: Text(
"Retour à l'accueil",
style: GoogleFonts.darkerGrotesque(
@@ -86,9 +98,13 @@ class ResultatRetourScreen extends StatelessWidget {
),
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.symmetric(
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(
10,
),
),
),
),
@@ -116,7 +132,9 @@ class ResultatRetourScreen extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.all(36),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]),
gradient: const LinearGradient(
colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
),
@@ -127,7 +145,9 @@ class ResultatRetourScreen extends StatelessWidget {
height: 72,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]),
gradient: LinearGradient(
colors: [Color(0xFF16A34A), Color(0xFF22C55E)],
),
),
child: const Icon(Icons.check, color: Colors.white, size: 36),
),
@@ -144,7 +164,10 @@ class ResultatRetourScreen extends StatelessWidget {
Text(
"L'emprunt a été clôturé et le matériel est de nouveau disponible.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)),
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: const Color(0xFF166534),
),
),
],
),
@@ -164,12 +187,19 @@ class ResultatRetourScreen extends StatelessWidget {
decoration: BoxDecoration(
color: EnsupColors.red.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.red.withValues(alpha: 0.2), width: 1.5),
border: Border.all(
color: EnsupColors.red.withValues(alpha: 0.2),
width: 1.5,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_rounded, color: EnsupColors.red, size: 24),
const Icon(
Icons.warning_amber_rounded,
color: EnsupColors.red,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -227,7 +257,10 @@ class ResultatRetourScreen extends StatelessWidget {
anomalie.etatRetour,
valeurCouleur: EnsupColors.red,
),
_drow("Matériel", "${emprunt.materiel.nom} · ${emprunt.materiel.reference}"),
_drow(
"Matériel",
"${emprunt.materiel.nom} · ${emprunt.materiel.reference}",
),
_drow("Emprunteur", DemoIdentity.userName),
_drow("Date détection", date),
],
@@ -237,7 +270,11 @@ class ResultatRetourScreen extends StatelessWidget {
Text(
"Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted, height: 1.6),
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
height: 1.6,
),
),
],
);
@@ -249,7 +286,13 @@ class ResultatRetourScreen extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
Text(
cle,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(width: 16),
Expanded(
child: Text(
+14 -35
View File
@@ -1,6 +1,7 @@
import "dart:convert";
import "dart:typed_data";
import "package:http/http.dart" as http;
import "auth_service.dart";
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
class ApiException implements Exception {
@@ -24,27 +25,20 @@ class ApiFile {
final String contentType;
}
/// Point d'accès unique au backend : centralise l'URL de base, l'en-tête
/// d'authentification (simulée) et la gestion des erreurs.
/// Point d'accès unique au backend : centralise l'URL, l'authentification et
/// la gestion des erreurs.
class ApiClient {
ApiClient._();
static const String _baseUrl = "http://localhost:3000/api";
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
static const String responsableEmail = "karim.benali@ensup.eu";
static Future<dynamic> get(
String chemin, {
String utilisateurEmail = _utilisateurEmail,
}) async {
static Future<dynamic> get(String chemin) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
reponse = await http.get(
uri,
headers: {"x-user-email": utilisateurEmail},
);
final authHeaders = await AuthService.requestHeaders();
reponse = await http.get(uri, headers: authHeaders);
} catch (_) {
throw const ApiException(
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
@@ -60,16 +54,13 @@ class ApiClient {
static Future<ApiFile> getFile(
String chemin, {
required String fallbackFileName,
String utilisateurEmail = _utilisateurEmail,
}) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
reponse = await http.get(
uri,
headers: {"x-user-email": utilisateurEmail},
);
final authHeaders = await AuthService.requestHeaders();
reponse = await http.get(uri, headers: authHeaders);
} catch (_) {
throw const ApiException(
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
@@ -88,21 +79,15 @@ class ApiClient {
);
}
static Future<dynamic> post(
String chemin,
Map<String, dynamic> body, {
String utilisateurEmail = _utilisateurEmail,
}) async {
static Future<dynamic> post(String chemin, Map<String, dynamic> body) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
final authHeaders = await AuthService.requestHeaders();
reponse = await http.post(
uri,
headers: {
"content-type": "application/json",
"x-user-email": utilisateurEmail,
},
headers: {...authHeaders, "content-type": "application/json"},
body: jsonEncode(body),
);
} catch (_) {
@@ -117,21 +102,15 @@ class ApiClient {
throw ApiException(_messageErreur(reponse));
}
static Future<dynamic> patch(
String chemin,
Map<String, dynamic> body, {
String utilisateurEmail = _utilisateurEmail,
}) async {
static Future<dynamic> patch(String chemin, Map<String, dynamic> body) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
final authHeaders = await AuthService.requestHeaders();
reponse = await http.patch(
uri,
headers: {
"content-type": "application/json",
"x-user-email": utilisateurEmail,
},
headers: {...authHeaders, "content-type": "application/json"},
body: jsonEncode(body),
);
} catch (_) {
+122
View File
@@ -0,0 +1,122 @@
import "../models/authenticated_profile.dart";
import "msal_bridge.dart";
enum AuthenticationMode { demo, azure }
class AuthService {
AuthService._();
static const String _configuredMode = String.fromEnvironment(
"AUTH_MODE",
defaultValue: "demo",
);
static const String azureTenantId = String.fromEnvironment("AZURE_TENANT_ID");
static const String azureSpaClientId = String.fromEnvironment(
"AZURE_SPA_CLIENT_ID",
);
static const String azureApiScope = String.fromEnvironment("AZURE_API_SCOPE");
static const String demoStudentEmail = "lucas.martin@ensitech.eu";
static const String demoResponsableEmail = "karim.benali@ensup.eu";
static String? _demoEmail;
static AuthenticatedProfile? _currentProfile;
static bool _azureInitialized = false;
static AuthenticatedProfile? get currentProfile => _currentProfile;
static AuthenticationMode get mode {
return switch (_configuredMode) {
"demo" => AuthenticationMode.demo,
"azure" => AuthenticationMode.azure,
_ => throw StateError("AUTH_MODE doit valoir demo ou azure."),
};
}
static void selectDemoStudent() {
_ensureDemoMode();
_demoEmail = demoStudentEmail;
}
static void selectDemoResponsable() {
_ensureDemoMode();
_demoEmail = demoResponsableEmail;
}
static void clearSession() {
_demoEmail = null;
_currentProfile = null;
}
static AuthenticatedProfile setProfileFromApi(dynamic reponse) {
final profile = AuthenticatedProfile.fromApi(reponse);
_currentProfile = profile;
return profile;
}
static Future<void> signInWithMicrosoft() async {
await _initializeAzure();
await MsalBridge.signIn();
}
static Future<bool> hasMicrosoftAccount() async {
await _initializeAzure();
return MsalBridge.hasAccount();
}
static Future<void> signOut() async {
if (mode == AuthenticationMode.azure) {
await _initializeAzure();
await MsalBridge.signOut();
_currentProfile = null;
return;
}
clearSession();
}
static Future<Map<String, String>> requestHeaders() async {
if (mode == AuthenticationMode.azure) {
await _initializeAzure();
final token = await MsalBridge.getAccessToken();
return {"authorization": "Bearer $token"};
}
final email = _demoEmail;
if (email == null) {
throw StateError("Aucun utilisateur de demonstration selectionne.");
}
return {"x-user-email": email};
}
static Future<void> _initializeAzure() async {
if (mode != AuthenticationMode.azure) {
throw StateError("MSAL est disponible uniquement en mode Azure.");
}
if (_azureInitialized) {
return;
}
if (azureTenantId.isEmpty ||
azureSpaClientId.isEmpty ||
azureApiScope.isEmpty) {
throw StateError("La configuration Azure du frontend est incomplete.");
}
await MsalBridge.initialize(
tenantId: azureTenantId,
clientId: azureSpaClientId,
scope: azureApiScope,
redirectUri: Uri.base.origin,
);
_azureInitialized = true;
}
static void _ensureDemoMode() {
if (mode != AuthenticationMode.demo) {
throw StateError(
"Une identite de demonstration est interdite en mode Azure.",
);
}
}
}
@@ -0,0 +1,56 @@
import "dart:js_interop";
@JS("emeMsal.initialize")
external JSPromise<JSAny?> _initialize(
JSString tenantId,
JSString clientId,
JSString scope,
JSString redirectUri,
);
@JS("emeMsal.signIn")
external JSPromise<JSAny?> _signIn();
@JS("emeMsal.getAccessToken")
external JSPromise<JSString> _getAccessToken();
@JS("emeMsal.hasAccount")
external JSBoolean _hasAccount();
@JS("emeMsal.signOut")
external JSPromise<JSAny?> _signOut();
class MsalBridge {
MsalBridge._();
static Future<void> initialize({
required String tenantId,
required String clientId,
required String scope,
required String redirectUri,
}) async {
await _initialize(
tenantId.toJS,
clientId.toJS,
scope.toJS,
redirectUri.toJS,
).toDart;
}
static Future<void> signIn() async {
await _signIn().toDart;
}
static Future<String> getAccessToken() async {
final token = await _getAccessToken().toDart;
return token.toDart;
}
static bool hasAccount() {
return _hasAccount().toDart;
}
static Future<void> signOut() async {
await _signOut().toDart;
}
}
@@ -4,13 +4,8 @@ import "api_client.dart";
class ResponsableService {
ResponsableService._();
static const String _email = ApiClient.responsableEmail;
static Future<Map<String, dynamic>> dashboard() async {
final reponse = await ApiClient.get(
"/responsable/dashboard",
utilisateurEmail: _email,
);
final reponse = await ApiClient.get("/responsable/dashboard");
return Map<String, dynamic>.from(reponse["data"] as Map);
}
@@ -32,14 +27,12 @@ class ResponsableService {
static Future<ApiFile> exporterHistorique() => ApiClient.getFile(
"/responsable/historique/export.csv",
fallbackFileName: "historique-responsable.csv",
utilisateurEmail: _email,
);
static Future<int> marquerNotificationsLues() async {
final reponse = await ApiClient.patch(
"/responsable/notifications/lu-toutes",
{},
utilisateurEmail: _email,
);
final data = Map<String, dynamic>.from(reponse["data"] as Map);
return data["count"] as int;
@@ -52,12 +45,12 @@ class ResponsableService {
final reponse = await ApiClient.patch("/responsable/anomalies/$id/statut", {
"statut": statut,
"observation": "Traitement depuis l'interface responsable",
}, utilisateurEmail: _email);
});
return Map<String, dynamic>.from(reponse["data"] as Map);
}
static Future<List<Map<String, dynamic>>> _liste(String chemin) async {
final reponse = await ApiClient.get(chemin, utilisateurEmail: _email);
final reponse = await ApiClient.get(chemin);
final data = reponse["data"] as List;
return data.map((item) => Map<String, dynamic>.from(item as Map)).toList();
}
+520
View File
@@ -0,0 +1,520 @@
{
"name": "eme-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@azure/msal-browser": "^5.17.1"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
},
"node_modules/@azure/msal-browser": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz",
"integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.11.2"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.11.2",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz",
"integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "eme-frontend-auth",
"private": true,
"scripts": {
"build:auth": "esbuild ./web/auth/msal_bridge.js --bundle --minify --format=iife --outfile=./web/msal_bridge.bundle.js"
},
"dependencies": {
"@azure/msal-browser": "^5.17.1"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}
+105
View File
@@ -0,0 +1,105 @@
import {
InteractionRequiredAuthError,
PublicClientApplication,
} from "@azure/msal-browser";
let msalInstance = null;
let apiScope = "";
function requireInstance() {
if (!msalInstance) {
throw new Error("MSAL n'est pas initialise.");
}
return msalInstance;
}
function activeAccount() {
const instance = requireInstance();
return instance.getActiveAccount() ?? instance.getAllAccounts()[0] ?? null;
}
async function initialize(tenantId, clientId, scope, redirectUri) {
if (msalInstance) {
return;
}
if (!tenantId || !clientId || !scope || !redirectUri) {
throw new Error("La configuration Azure du frontend est incomplete.");
}
apiScope = scope;
msalInstance = await PublicClientApplication.createPublicClientApplication({
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
redirectUri,
postLogoutRedirectUri: redirectUri,
},
cache: {
cacheLocation: "sessionStorage",
},
});
const account = activeAccount();
if (account) {
msalInstance.setActiveAccount(account);
}
}
async function signIn() {
const instance = requireInstance();
const result = await instance.loginPopup({
scopes: ["openid", "profile", "email", apiScope],
});
instance.setActiveAccount(result.account);
}
async function getAccessToken() {
const instance = requireInstance();
const account = activeAccount();
if (!account) {
throw new Error("Aucun compte Microsoft connecte.");
}
try {
const result = await instance.acquireTokenSilent({
account,
scopes: [apiScope],
});
return result.accessToken;
} catch (error) {
if (!(error instanceof InteractionRequiredAuthError)) {
throw error;
}
const result = await instance.acquireTokenPopup({
account,
scopes: [apiScope],
});
return result.accessToken;
}
}
function hasAccount() {
return activeAccount() !== null;
}
async function signOut() {
const instance = requireInstance();
const account = activeAccount();
if (!account) {
return;
}
await instance.logoutPopup({ account });
}
globalThis.emeMsal = {
getAccessToken,
hasAccount,
initialize,
signIn,
signOut,
};
+1
View File
@@ -33,6 +33,7 @@
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="msal_bridge.bundle.js"></script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+21 -5
View File
@@ -37,7 +37,7 @@ l'historique complet, y compris des étapes devenues obsolètes après brancheme
| Base de données | Terminé pour la V1 | Schéma Prisma 16 entités, migrations, seeds et fixtures de démonstration. |
| API étudiant | Terminé pour la V1 | Catalogue, détail matériel, création d'emprunt, mes emprunts, restitution, anomalie automatique. |
| Frontend étudiant | Fonctionnel pour la V1 | Parcours API complet, ordre identification puis choix validé, interface premium et responsive fidèle à la maquette. |
| Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. |
| Authentification | Prête pour Azure, démo active | Double mode sécurisé : `x-user-email` seulement en développement, MSAL/OIDC + Bearer JWT en mode Azure. Validation réelle en attente des identifiants Entra ID. |
| API responsable | Terminé pour la V1 | Dashboard, emprunts, stock, anomalies, notifications, historique et export CSV, avec contrôle du rôle et du campus. |
| Frontend responsable | Fonctionnel pour la V1 | Espace de supervision responsive avec navigation dédiée, dashboard métier, vues API et export CSV opérationnel. |
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
@@ -56,6 +56,7 @@ npm run lint
npm run seed
npm run fixtures
cd ../eme-frontend
npm run build:auth
flutter run -d web-server --web-port 5000
C:\flutter\flutter\bin\cache\dart-sdk\bin\dart.exe analyze
```
@@ -64,7 +65,8 @@ Notes :
- le backend écoute sur `http://localhost:3000` ;
- le frontend web de démo écoute sur `http://localhost:5000` ;
- Adminer est disponible sur `http://localhost:8081` ;
- le wrapper `flutter` peut rester bloqué dans l'environnement Codex ; l'analyse directe avec l'exécutable Dart fonctionne et ne signale aucune erreur.
- le wrapper `flutter` peut rester bloqué dans l'environnement Codex ; l'analyse directe avec l'exécutable Dart fonctionne et ne signale aucune erreur ;
- la compilation Web complète a été validée avec l'exécutable Dart du SDK Flutter et produit `build/web`.
## Scénario de démo validé
@@ -83,7 +85,7 @@ Scénario responsable validé avec SQL Server Docker et backend compilé :
2. Lucas (`ETUDIANT`) reçoit une réponse `403` sur les routes responsable.
3. Une anomalie peut avancer dans le cycle `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
4. Chaque transition enregistre le responsable et crée une entrée d'historique.
5. L'API génère l'export CSV de l'historique ; son téléchargement depuis l'interface Flutter reste à ajouter.
5. L'API génère l'export CSV de l'historique et l'interface Flutter déclenche son téléchargement.
### Note outillage — migrations en environnement non-interactif
@@ -269,7 +271,7 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
| `Materiel.statut` -> `@default("DISPONIBLE")` | À faire avec migration + ERD | Faible : le code fournit déjà le statut explicitement. |
| Tailles de colonnes `@db.NVarChar(n)` | À cadrer | Faible V1, important pour qualité BDD. |
| Swagger/OpenAPI | À faire | Moyen : utile pour tester/documenter l'API. |
| Auth réelle Azure AD | À faire | Fort : requis pour sortir de la démo avec auth simulée. |
| Validation réelle Azure AD | En attente des identifiants Entra ID | Fort : le code OIDC/MSAL/JWT est prêt, mais une connexion Microsoft réelle doit encore être testée sur le tenant ENSUP. |
| Limite d'emprunts actifs par étudiant | À valider métier | Non bloquant V1 ; décision métier non présente dans les règles actuelles. |
---
@@ -442,6 +444,20 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Écrans de confirmation et de résultat conservés fonctionnellement et harmonisés via les composants partagés.
- Vérification statique : `dart analyze` sans erreur.
### Étape 47 — Sécurisation SSO Microsoft Entra ID
- Branche dédiée `feat/block-3-azure-sso` créée depuis `develop`.
- Backend configuré avec deux modes : `demo` en développement et `azure` pour une authentification réelle ; le mode démo est explicitement interdit en production.
- Middleware d'authentification refactoré vers un service dédié. En mode Azure, `x-user-email` est ignoré et un Bearer Token Microsoft est obligatoire.
- Validation JWT ajoutée : signature RSA via les clés JWKS Microsoft, algorithme `RS256`, issuer, audience, tenant, scope `access_as_user`, identifiant `oid` et domaines `ensup.eu` / `ensitech.eu`.
- L'utilisateur, son rôle et son campus restent résolus depuis `eme_db`. Un étudiant obtient `403` sur la supervision, tandis que le responsable de démonstration obtient `200`.
- Frontend doté d'un double mode de session. La simulation sélectionne Lucas ou Karim sans pouvoir être utilisée en mode Azure.
- Bibliothèque officielle `@azure/msal-browser` intégrée via un bundle local construit avec esbuild et un pont typé `dart:js_interop`.
- Flux OpenID Connect préparé avec connexion Microsoft, acquisition silencieuse de l'Access Token, appel de `/api/auth/me`, navigation selon le rôle local, restauration après actualisation et déconnexion MSAL.
- Profil authentifié typé et affiché dans les espaces étudiant et responsable : nom, initiales, classe, rôle et campus ne sont plus figés sur les identités de démonstration.
- Vérifications réussies : build et lint backend, analyse Dart, compilation Flutter Web complète et contrôles runtime des rôles en mode démo.
- Limite actuelle : faute d'identifiants Entra ID, le flux Microsoft réel n'a pas encore pu être exécuté. Le QR sécurisé reste un sous-bloc séparé à développer après le SSO.
- Commits : `313bb1c`, `530bd52`, `ef31e2a`, `dc208fa`, `36224f3`.
---
*Dernière mise à jour : 2026-07-23 — Espace étudiant premium et responsive, fidèle à la maquette ENSUP.*
*Dernière mise à jour : 2026-07-23 — SSO Microsoft Entra ID prêt à configurer, mode démo sécurisé actif.*