feat: v8 - skill itinova-user-management (3 profils admin/standard/readonly, logo FEHAP, login/email)
This commit is contained in:
@@ -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
|
||||
|
||||
41
server/_core/storageProxy.ts
Normal file
41
server/_core/storageProxy.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
68
server/db.ts
68
server/db.ts
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user