feat: v8 - skill itinova-user-management (3 profils admin/standard/readonly, logo FEHAP, login/email)

This commit is contained in:
Manus Deploy
2026-04-21 06:51:07 -04:00
parent 65e345459c
commit a8b1784e28
14 changed files with 1356 additions and 219 deletions

View File

@@ -4,6 +4,7 @@ import { createServer } from "http";
import net from "net";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { registerStorageProxy } from "./storageProxy";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
@@ -33,6 +34,8 @@ async function startServer() {
// Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
// Storage proxy for /manus-storage/* paths
registerStorageProxy(app);
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// tRPC API

View File

@@ -0,0 +1,41 @@
import type { Express } from "express";
import { ENV } from "./env";
export function registerStorageProxy(app: Express) {
app.get("/manus-storage/*", async (req, res) => {
const key = (req.params as Record<string, string>)[0];
if (!key) {
res.status(400).send("Missing storage key");
return;
}
if (!ENV.forgeApiUrl || !ENV.forgeApiKey) {
res.status(500).send("Storage proxy not configured");
return;
}
try {
const forgeUrl = new URL(
"v1/storage/presign/get",
ENV.forgeApiUrl.replace(/\/+$/, "") + "/",
);
forgeUrl.searchParams.set("path", key);
const forgeResp = await fetch(forgeUrl, {
headers: { Authorization: `Bearer ${ENV.forgeApiKey}` },
});
if (!forgeResp.ok) {
const body = await forgeResp.text().catch(() => "");
console.error(`[StorageProxy] forge error: ${forgeResp.status} ${body}`);
res.status(502).send("Storage backend error");
return;
}
const { url } = (await forgeResp.json()) as { url: string };
if (!url) {
res.status(502).send("Empty signed URL from backend");
return;
}
res.set("Cache-Control", "no-store");
res.redirect(307, url);
} catch (err) {
console.error("[StorageProxy] failed:", err);
res.status(502).send("Storage proxy error");
}
});
}

View File

@@ -421,9 +421,14 @@ import { nanoid } from "nanoid";
/** Crée un utilisateur local (sans openId OAuth) avec un mot de passe hashé. */
export async function createLocalUser(data: {
name: string;
name?: string;
firstName?: string;
lastName?: string;
login?: string;
email: string;
sonumRole: "referent" | "gestionnaire" | "adherent";
role?: "admin" | "standard" | "readonly";
isActive?: boolean;
password: string;
}) {
const db = await getDb();
@@ -433,16 +438,31 @@ export async function createLocalUser(data: {
const existing = await db.select().from(users).where(eq(users.email, data.email)).limit(1);
if (existing.length > 0) throw new Error("EMAIL_EXISTS");
// Vérifier unicité login si fourni
if (data.login) {
const existingLogin = await db.select().from(users).where(eq(users.login, data.login)).limit(1);
if (existingLogin.length > 0) throw new Error("LOGIN_EXISTS");
}
// openId synthétique pour les comptes locaux
const syntheticOpenId = `local_${nanoid(16)}`;
const passwordHash = await bcrypt.hash(data.password, 12);
// Dériver name si non fourni
const fullName = data.name ??
(data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` : data.email);
const insertResult = await db.insert(users).values({
openId: syntheticOpenId,
name: data.name,
login: data.login ?? null,
name: fullName,
firstName: data.firstName ?? null,
lastName: data.lastName ?? null,
email: data.email,
loginMethod: "local",
sonumRole: data.sonumRole,
role: data.role ?? "standard",
isActive: data.isActive !== undefined ? data.isActive : true,
cguAccepted: false,
lastSignedIn: new Date(),
});
@@ -455,24 +475,37 @@ export async function createLocalUser(data: {
return userId;
}
/** Authentifie un utilisateur par email + mot de passe. Retourne l'utilisateur ou null. */
export async function authenticateLocalUser(email: string, password: string) {
/** Authentifie un utilisateur par login (email ou login court) + mot de passe. Retourne l'utilisateur ou null. */
export async function authenticateLocalUser(loginOrEmail: string, password: string) {
const db = await getDb();
if (!db) return null;
const result = await db
.select({
user: users,
passwordHash: localCredentials.passwordHash,
})
// Chercher par email OU par login court
const byEmail = await db
.select({ user: users, passwordHash: localCredentials.passwordHash })
.from(users)
.innerJoin(localCredentials, eq(localCredentials.userId, users.id))
.where(eq(users.email, email))
.where(eq(users.email, loginOrEmail))
.limit(1);
let result = byEmail;
if (!result.length) {
const byLogin = await db
.select({ user: users, passwordHash: localCredentials.passwordHash })
.from(users)
.innerJoin(localCredentials, eq(localCredentials.userId, users.id))
.where(eq(users.login, loginOrEmail))
.limit(1);
result = byLogin;
}
if (!result.length) return null;
const { user, passwordHash } = result[0];
// Vérifier que le compte est actif
if (!user.isActive) return null;
const valid = await bcrypt.compare(password, passwordHash);
if (!valid) return null;
@@ -506,12 +539,25 @@ export async function updateLocalPassword(userId: number, newPassword: string) {
/** Met à jour les informations d'un utilisateur. */
export async function updateUser(userId: number, data: {
name?: string;
firstName?: string;
lastName?: string;
login?: string;
email?: string;
sonumRole?: "referent" | "gestionnaire" | "adherent";
role?: "admin" | "standard" | "readonly";
isActive?: boolean;
}) {
const db = await getDb();
if (!db) return;
await db.update(users).set({ ...data, updatedAt: new Date() }).where(eq(users.id, userId));
// Dériver name si firstName/lastName fournis
const updateData: Record<string, unknown> = { ...data, updatedAt: new Date() };
if (data.firstName !== undefined || data.lastName !== undefined) {
const current = await db.select().from(users).where(eq(users.id, userId)).limit(1);
const fn = data.firstName ?? current[0]?.firstName ?? "";
const ln = data.lastName ?? current[0]?.lastName ?? "";
if (fn || ln) updateData.name = `${fn} ${ln}`.trim();
}
await db.update(users).set(updateData as any).where(eq(users.id, userId));
}
/** Supprime un utilisateur et ses credentials locaux. */

View File

@@ -65,6 +65,14 @@ const gestionnaireProcedure = protectedProcedure.use(({ ctx, next }) => {
return next({ ctx });
});
/** Bloque les mutations pour les utilisateurs en lecture seule (role === 'readonly') */
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role === "readonly") {
throw new TRPCError({ code: "FORBIDDEN", message: "Votre compte est en lecture seule. Contactez un gestionnaire SONUM pour obtenir les droits de modification." });
}
return next({ ctx });
});
// ─── Router principal ─────────────────────────────────────────────────────────
export const appRouter = router({
@@ -86,13 +94,14 @@ export const appRouter = router({
*/
loginLocal: publicProcedure
.input(z.object({
email: z.string().email(),
// Accepte email ou login court
email: z.string().min(1),
password: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
const user = await authenticateLocalUser(input.email, input.password);
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Email ou mot de passe incorrect" });
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect" });
}
// Créer un token de session avec l'openId de l'utilisateur local
@@ -325,12 +334,14 @@ export const appRouter = router({
if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
if (ctx.user.role === "readonly") throw new TRPCError({ code: "FORBIDDEN", message: "Compte en lecture seule" });
return upsertLogicielEtablissement({ ...input, saisiePar: ctx.user.id });
}),
delete: protectedProcedure
.input(z.object({ id: z.number().int(), etablissementId: z.number().int() }))
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "readonly") throw new TRPCError({ code: "FORBIDDEN", message: "Compte en lecture seule" });
const etab = await getEtablissementById(input.etablissementId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });
if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
@@ -379,7 +390,7 @@ export const appRouter = router({
// ─── Demandes de Contact ───────────────────────────────────────────────────
contact: router({
envoyer: protectedProcedure
envoyer: writeProcedure
.input(z.object({
etablissementCibleId: z.number().int(),
message: z.string().min(1),
@@ -438,9 +449,13 @@ export const appRouter = router({
/** Crée un utilisateur manuellement avec un mot de passe local */
createUser: gestionnaireProcedure
.input(z.object({
name: z.string().min(1),
firstName: z.string().min(1),
lastName: z.string().min(1),
login: z.string().min(2).optional(),
email: z.string().email(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
role: z.enum(["admin", "standard", "readonly"]).default("standard"),
isActive: z.boolean().default(true),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
}))
.mutation(async ({ input }) => {
@@ -451,6 +466,9 @@ export const appRouter = router({
if (err.message === "EMAIL_EXISTS") {
throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec cet email existe déjà" });
}
if (err.message === "LOGIN_EXISTS") {
throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec ce login existe déjà" });
}
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: err.message });
}
}),
@@ -459,9 +477,13 @@ export const appRouter = router({
updateUser: gestionnaireProcedure
.input(z.object({
userId: z.number().int(),
name: z.string().min(1).optional(),
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
login: z.string().min(2).optional(),
email: z.string().email().optional(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]).optional(),
role: z.enum(["admin", "standard", "readonly"]).optional(),
isActive: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { userId, ...data } = input;