From 4ffafef3f1a91860d96eaa7537a2a3e6aa9e93d1 Mon Sep 17 00:00:00 2001 From: flfeders Date: Thu, 15 Oct 2026 14:22:12 +0200 Subject: [PATCH] feat(admin): archive users and their profiles permanently --- backend/db/migrations/0082_archive_users.sql | 3 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/auth_profiles.ts | 1 + backend/db/schema/auth_users.ts | 1 + backend/src/plugins/auth.m2m.ts | 7 +- backend/src/plugins/auth.ts | 29 +-- backend/src/routes/admin.ts | 46 +++- backend/src/routes/adminUserArchive.ts | 51 ++++ backend/src/routes/auth/auth.ts | 6 +- backend/src/routes/auth/user.ts | 9 +- backend/src/routes/communication.ts | 9 +- backend/src/routes/devices/rfid.ts | 3 +- backend/src/routes/internal/tenant.ts | 10 +- backend/src/routes/profiles.ts | 7 +- backend/src/routes/resources/main.ts | 37 ++- backend/src/routes/tenant.ts | 14 +- backend/src/utils/authTokens.ts | 2 +- backend/src/utils/calendarSubscription.ts | 5 +- backend/src/utils/requireSystemAdmin.ts | 4 +- backend/tests/helpers/collaboration-db.ts | 2 +- backend/tests/mcpDocuments.test.ts | 1 + backend/tests/userArchive.test.ts | 233 ++++++++++++++++++ frontend/composables/useAdmin.ts | 11 +- frontend/pages/administration/users/[id].vue | 74 +++++- frontend/pages/administration/users/index.vue | 8 +- frontend/stores/profile.js | 14 +- 26 files changed, 519 insertions(+), 75 deletions(-) create mode 100644 backend/db/migrations/0082_archive_users.sql create mode 100644 backend/src/routes/adminUserArchive.ts create mode 100644 backend/tests/userArchive.test.ts diff --git a/backend/db/migrations/0082_archive_users.sql b/backend/db/migrations/0082_archive_users.sql new file mode 100644 index 00000000..a7e4df4a --- /dev/null +++ b/backend/db/migrations/0082_archive_users.sql @@ -0,0 +1,3 @@ +ALTER TABLE "auth_users" ADD COLUMN "archived_at" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "auth_profiles" ADD COLUMN "archived_at" timestamp with time zone; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 4185dade..262a7a6b 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -554,6 +554,13 @@ "when": 1791244800000, "tag": "0081_chat_media_reactions", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1792022400000, + "tag": "0082_archive_users", + "breakpoints": true } ] } diff --git a/backend/db/schema/auth_profiles.ts b/backend/db/schema/auth_profiles.ts index a9b66274..2f338e76 100644 --- a/backend/db/schema/auth_profiles.ts +++ b/backend/db/schema/auth_profiles.ts @@ -82,6 +82,7 @@ export const authProfiles = pgTable("auth_profiles", { address_city: text("address_city"), active: boolean("active").notNull().default(true), + archived_at: timestamp("archived_at", { withTimezone: true }), }) export type AuthProfile = typeof authProfiles.$inferSelect diff --git a/backend/db/schema/auth_users.ts b/backend/db/schema/auth_users.ts index 0bec516e..85d51a37 100644 --- a/backend/db/schema/auth_users.ts +++ b/backend/db/schema/auth_users.ts @@ -13,6 +13,7 @@ export const authUsers = pgTable("auth_users", { multiTenant: boolean("multi_tenant").notNull().default(true), must_change_password: boolean("must_change_password").notNull().default(false), is_admin: boolean("is_admin").notNull().default(false), + archived_at: timestamp("archived_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }), diff --git a/backend/src/plugins/auth.m2m.ts b/backend/src/plugins/auth.m2m.ts index 70ebb707..9fc82ad1 100644 --- a/backend/src/plugins/auth.m2m.ts +++ b/backend/src/plugins/auth.m2m.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from "fastify"; import fp from "fastify-plugin"; import { secrets } from "../utils/secrets"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { authUsers, m2mApiKeys } from "../../db/schema"; import { createHash } from "node:crypto"; @@ -44,7 +44,8 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) .where(and( eq(m2mApiKeys.keyHash, keyHash), - eq(m2mApiKeys.active, true) + eq(m2mApiKeys.active, true), + isNull(authUsers.archived_at) )) .limit(1) @@ -70,7 +71,7 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string const users = await server.db .select({ email: authUsers.email }) .from(authUsers) - .where(eq(authUsers.id, userId)) + .where(and(eq(authUsers.id, userId), isNull(authUsers.archived_at))) .limit(1) if (!users[0]) { diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 5a7a207d..cf4769a3 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -12,7 +12,7 @@ import { tenants, } from "../../db/schema" -import { eq, and, inArray } from "drizzle-orm" +import { eq, and, inArray, isNull } from "drizzle-orm" export default fp(async (server: FastifyInstance) => { const hashApiKey = (apiKey: string) => @@ -56,7 +56,8 @@ export default fp(async (server: FastifyInstance) => { .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) .where(and( eq(m2mApiKeys.keyHash, keyHash), - eq(m2mApiKeys.active, true) + eq(m2mApiKeys.active, true), + isNull(authUsers.archived_at) )) .limit(1) @@ -141,22 +142,16 @@ export default fp(async (server: FastifyInstance) => { return reply.code(401).send({ error: "Invalid token" }) } - // Payload an Request hängen - req.user = payload - - if (typeof payload.is_admin === "boolean") { - req.user.is_admin = payload.is_admin - } else { - const [currentUser] = await server.db - .select({ - is_admin: authUsers.is_admin, - }) - .from(authUsers) - .where(eq(authUsers.id, payload.user_id)) - .limit(1) - - req.user.is_admin = Boolean(currentUser?.is_admin) + // Check current account state even when a JWT carries cached admin claims. + const [currentUser] = await server.db + .select({ is_admin: authUsers.is_admin }) + .from(authUsers) + .where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at))) + .limit(1) + if (!currentUser) { + return reply.code(401).send({ error: "Invalid or expired token" }) } + req.user = { ...payload, is_admin: Boolean(currentUser.is_admin) } // Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung if (!req.user.tenant_id) { diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index df09bb89..03f5fcb0 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -33,9 +33,12 @@ import { requireSystemAdmin } from "../utils/requireSystemAdmin"; import { buildSystemStatus } from "../modules/system-status.service"; import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; +import adminUserArchiveRoutes from "./adminUserArchive"; import { getPortalLoginUrl } from "../utils/portalUrl"; export default async function adminRoutes(server: FastifyInstance) { + await adminUserArchiveRoutes(server); + await server.register(multipart, { limits: { fileSize: 1024 * 1024 * 1024 } }); @@ -602,6 +605,7 @@ export default async function adminRoutes(server: FastifyInstance) { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; + const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true"; const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([ server.db .select({ @@ -621,8 +625,9 @@ export default async function adminRoutes(server: FastifyInstance) { multiTenant: authUsers.multiTenant, must_change_password: authUsers.must_change_password, is_admin: authUsers.is_admin, + archived_at: authUsers.archived_at, }) - .from(authUsers), + .from(authUsers).where(includeArchived ? undefined : isNull(authUsers.archived_at)), server.db .select({ id: authProfiles.id, @@ -633,6 +638,7 @@ export default async function adminRoutes(server: FastifyInstance) { full_name: authProfiles.full_name, email: authProfiles.email, active: authProfiles.active, + archived_at: authProfiles.archived_at, }) .from(authProfiles), server.db @@ -681,16 +687,17 @@ export default async function adminRoutes(server: FastifyInstance) { }; }); + const activeUserIds = new Set(userRows.filter((user) => !user.archived_at).map((user) => user.id)); const tenantsWithCounts = tenantRows.map((tenant) => ({ ...tenant, - user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length, + user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id && activeUserIds.has(membership.user_id)).length, })); return { users, tenants: tenantsWithCounts, roles: roleRows, - unassignedProfiles: profileRows.filter((profile) => !profile.user_id), + unassignedProfiles: profileRows.filter((profile) => !profile.user_id && !profile.archived_at && profile.active), memberships: uniqueMembershipRows, roleAssignments: roleAssignmentRows, }; @@ -809,7 +816,7 @@ export default async function adminRoutes(server: FastifyInstance) { email: authProfiles.email, }) .from(authProfiles) - .where(eq(authProfiles.id, profileId)) + .where(and(eq(authProfiles.id, profileId), isNull(authProfiles.archived_at))) .limit(1); if (!profile) { @@ -951,11 +958,16 @@ export default async function adminRoutes(server: FastifyInstance) { id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin, + archived_at: authUsers.archived_at, }) .from(authUsers) .where(eq(authUsers.email, email)) .limit(1); + if (existingUser?.archived_at) { + return reply.code(409).send({ error: "Dieses Benutzerkonto ist dauerhaft archiviert." }); + } + const derivedName = deriveNameFromEmail(email); const firstName = customerRecord.firstname?.trim() || derivedName.first_name; const lastName = customerRecord.lastname?.trim() || derivedName.last_name; @@ -1187,6 +1199,12 @@ export default async function adminRoutes(server: FastifyInstance) { if (!currentUser) return; const { user_id } = req.params as { user_id: string }; + const [account] = await server.db.select({ archived_at: authUsers.archived_at }) + .from(authUsers).where(eq(authUsers.id, user_id)).limit(1); + if (!account) return reply.code(404).send({ error: "User not found" }); + if (account.archived_at) { + return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." }); + } const body = req.body as { email?: string; multiTenant?: boolean; @@ -1236,6 +1254,12 @@ export default async function adminRoutes(server: FastifyInstance) { if (!currentUser) return; const { user_id } = req.params as { user_id: string }; + const [account] = await server.db.select({ archived_at: authUsers.archived_at }) + .from(authUsers).where(eq(authUsers.id, user_id)).limit(1); + if (!account) return reply.code(404).send({ error: "User not found" }); + if (account.archived_at) { + return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." }); + } const body = (req.body || {}) as { password?: string }; const requestedPassword = body.password?.trim(); @@ -1636,6 +1660,12 @@ export default async function adminRoutes(server: FastifyInstance) { if (!currentUser) return; const { user_id } = req.params as { user_id: string }; + const [account] = await server.db.select({ archived_at: authUsers.archived_at }) + .from(authUsers).where(eq(authUsers.id, user_id)).limit(1); + if (!account) return reply.code(404).send({ error: "User not found" }); + if (account.archived_at) { + return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." }); + } const body = req.body as { tenant_ids?: number[]; role_assignments?: { tenant_id: number; role_id: string }[]; @@ -1712,7 +1742,9 @@ export default async function adminRoutes(server: FastifyInstance) { .where( and( inArray(authProfiles.tenant_id, tenantIds), - isNull(authProfiles.user_id) + isNull(authProfiles.user_id), + isNull(authProfiles.archived_at), + eq(authProfiles.active, true) ) ) : []; @@ -1835,6 +1867,10 @@ export default async function adminRoutes(server: FastifyInstance) { }); } + const [targetUser] = await server.db.select({ id: authUsers.id }) + .from(authUsers).where(and(eq(authUsers.id, body.user_id), isNull(authUsers.archived_at))).limit(1); + if (!targetUser) return reply.code(404).send({ error: "Aktiver Benutzer nicht gefunden." }); + const mode = body.mode ?? "multi"; // ---------------------------- diff --git a/backend/src/routes/adminUserArchive.ts b/backend/src/routes/adminUserArchive.ts new file mode 100644 index 00000000..c3f5aaa2 --- /dev/null +++ b/backend/src/routes/adminUserArchive.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from "fastify" +import { eq } from "drizzle-orm" +import { authProfiles, authRefreshTokens, authUsers, m2mApiKeys } from "../../db/schema" +import { requireSystemAdmin } from "../utils/requireSystemAdmin" + +export default async function adminUserArchiveRoutes(server: FastifyInstance) { + server.post("/admin/users/:user_id/archive", { + schema: { + params: { + type: "object", + required: ["user_id"], + properties: { user_id: { type: "string", format: "uuid" } }, + }, + }, + }, async (req, reply) => { + const currentUser = await requireSystemAdmin(server, req, reply) + if (!currentUser) return + + const { user_id } = req.params as { user_id: string } + if (user_id === currentUser.id) { + return reply.code(409).send({ error: "Das eigene Benutzerkonto kann nicht archiviert werden." }) + } + + const user = await server.db.transaction(async (tx) => { + // Serialize repeated archive requests and keep the original archive date. + const [target] = await tx.select({ id: authUsers.id, archived_at: authUsers.archived_at }) + .from(authUsers).where(eq(authUsers.id, user_id)).limit(1).for("update") + if (!target) return null + + const archivedAt = target.archived_at || new Date() + const [archivedUser] = await tx.update(authUsers) + .set({ archived_at: archivedAt, updatedAt: archivedAt }) + .where(eq(authUsers.id, user_id)) + .returning({ id: authUsers.id, archived_at: authUsers.archived_at }) + + // Keep links intact so existing documents and time records retain their authors. + await tx.update(authProfiles) + .set({ active: false, archived_at: archivedAt, token_id: null, calendar_subscription_token: null }) + .where(eq(authProfiles.user_id, user_id)) + await tx.update(authRefreshTokens).set({ revokedAt: archivedAt }) + .where(eq(authRefreshTokens.userId, user_id)) + await tx.update(m2mApiKeys).set({ active: false, updatedAt: archivedAt }) + .where(eq(m2mApiKeys.userId, user_id)) + + return archivedUser + }) + + if (!user) return reply.code(404).send({ error: "Benutzer nicht gefunden." }) + return { user } + }) +} diff --git a/backend/src/routes/auth/auth.ts b/backend/src/routes/auth/auth.ts index ab799a07..612f554a 100644 --- a/backend/src/routes/auth/auth.ts +++ b/backend/src/routes/auth/auth.ts @@ -6,7 +6,7 @@ import { sendMail } from "../../utils/mailer"; import { authUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema"; import { tenants } from "../../../db/schema"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { resolveLoginTenant } from "../../utils/loginTenant"; import { createAccessToken, @@ -112,7 +112,7 @@ export default async function authRoutes(server: FastifyInstance) { const [found] = await server.db .select() .from(authUsers) - .where(eq(authUsers.email, body.email.toLowerCase())) + .where(and(eq(authUsers.email, body.email.toLowerCase()), isNull(authUsers.archived_at))) .limit(1); if (!found) { @@ -216,7 +216,7 @@ export default async function authRoutes(server: FastifyInstance) { mustChangePassword: authUsers.must_change_password, }) .from(authUsers) - .where(eq(authUsers.email, email.toLowerCase())) + .where(and(eq(authUsers.email, email.toLowerCase()), isNull(authUsers.archived_at))) .limit(1); if (!user) { diff --git a/backend/src/routes/auth/user.ts b/backend/src/routes/auth/user.ts index fde299ad..3cbf647b 100644 --- a/backend/src/routes/auth/user.ts +++ b/backend/src/routes/auth/user.ts @@ -1,5 +1,5 @@ import { FastifyInstance } from "fastify" -import { eq, and } from "drizzle-orm" +import { eq, and, isNull } from "drizzle-orm" import { authUsers, @@ -83,7 +83,8 @@ export default async function userRoutes(server: FastifyInstance) { .where( and( eq(authProfiles.user_id, id), - eq(authProfiles.tenant_id, req.user.tenant_id) + eq(authProfiles.tenant_id, req.user.tenant_id), + isNull(authProfiles.archived_at) ) ) @@ -99,6 +100,7 @@ export default async function userRoutes(server: FastifyInstance) { } const updateData: any = { ...data } + for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete updateData[key] // bekannte Date-Felder prüfen if (data.entry_date !== undefined) @@ -116,9 +118,10 @@ export default async function userRoutes(server: FastifyInstance) { const [updatedProfile] = await server.db .update(authProfiles) .set(updateData) - .where(eq(authProfiles.id, profile.id)) + .where(and(eq(authProfiles.id, profile.id), isNull(authProfiles.archived_at))) .returning() + if (!updatedProfile) return reply.code(404).send({ error: "Profile not found in tenant" }) return { profile: updatedProfile } } catch (err: any) { diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 32e1b5d9..db58e290 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" import { FastifyInstance } from "fastify" -import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm" +import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm" import { authProfiles, authTenantUsers, @@ -80,7 +80,7 @@ export default async function communicationRoutes(server: FastifyInstance) { eq(authProfiles.user_id, authTenantUsers.user_id), eq(authProfiles.tenant_id, tenantId) )) - .where(eq(authTenantUsers.tenant_id, tenantId)) + .where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at))) const addMembers = async (roomId: string, userIds: string[]) => { const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean))) @@ -122,7 +122,8 @@ export default async function communicationRoutes(server: FastifyInstance) { .where(eq(communicationRooms.id, room.id)).returning() } const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers) - .where(eq(authTenantUsers.tenant_id, tenantId)) + .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) + .where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at))) if (users.length) await tx.insert(communicationRoomMembers) .values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId }))) .onConflictDoNothing() @@ -372,7 +373,7 @@ export default async function communicationRoutes(server: FastifyInstance) { const profileIds = (project.profiles || []) as string[] const memberProfiles = profileIds.length ? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles) - .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds))) + .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds), eq(authProfiles.active, true))) : [] const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[] await addMembers(room.id, [req.user.user_id, ...projectMemberIds]) diff --git a/backend/src/routes/devices/rfid.ts b/backend/src/routes/devices/rfid.ts index e8c8e48f..9be7c3ae 100644 --- a/backend/src/routes/devices/rfid.ts +++ b/backend/src/routes/devices/rfid.ts @@ -44,7 +44,8 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) { .where( and( eq(authProfiles.tenant_id, device.tenant), - eq(authProfiles.token_id, rfid_id) + eq(authProfiles.token_id, rfid_id), + eq(authProfiles.active, true) ) ) .limit(1) diff --git a/backend/src/routes/internal/tenant.ts b/backend/src/routes/internal/tenant.ts index 527e6e52..8b6e4269 100644 --- a/backend/src/routes/internal/tenant.ts +++ b/backend/src/routes/internal/tenant.ts @@ -7,7 +7,7 @@ import { tenants } from "../../../db/schema" -import {and, eq, inArray} from "drizzle-orm" +import {and, eq, inArray, isNull} from "drizzle-orm" import { enrichProfilesWithBranches } from "../../utils/profileBranches" import { enrichProfilesWithTeams } from "../../utils/profileTeams" @@ -52,7 +52,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { const users = await server.db .select() .from(authUsers) - .where(inArray(authUsers.id, userIds)) + .where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at))) // 3) auth_profiles pro Tenant laden const profileRows = await server.db @@ -61,7 +61,9 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { .where( and( eq(authProfiles.tenant_id, tenantId), - inArray(authProfiles.user_id, userIds) + inArray(authProfiles.user_id, userIds), + eq(authProfiles.active, true), + isNull(authProfiles.archived_at) )) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) @@ -98,7 +100,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { const profileRows = await server.db .select() .from(authProfiles) - .where(eq(authProfiles.tenant_id, tenantId)) + .where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.archived_at))) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) return await enrichProfilesWithTeams(server, profilesWithBranches) diff --git a/backend/src/routes/profiles.ts b/backend/src/routes/profiles.ts index 09c1c66e..a50fdd66 100644 --- a/backend/src/routes/profiles.ts +++ b/backend/src/routes/profiles.ts @@ -1,5 +1,5 @@ import { FastifyInstance } from "fastify"; -import { eq, and } from "drizzle-orm"; +import { eq, and, isNull } from "drizzle-orm"; import { authProfiles, @@ -57,7 +57,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) { const forbidden = [ "id", "user_id", "tenant_id", "created_at", "updated_at", "updatedAt", "updatedBy", "old_profile_id", "full_name", - "branch", "calendar_subscription_token", + "branch", "archived_at", "calendar_subscription_token", "calendar_subscription_path", "calendar_subscription_url" ] forbidden.forEach(f => delete cleaned[f]) @@ -135,7 +135,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) { .where( and( eq(authProfiles.id, id), - eq(authProfiles.tenant_id, tenantId) + eq(authProfiles.tenant_id, tenantId), + isNull(authProfiles.archived_at) ) ) .returning() diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index d69232d6..bdf34cd8 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -9,12 +9,14 @@ import { and, count, inArray, + isNull, or, sql, } from "drizzle-orm" import { authProfiles, + authUsers, createddocuments, customers, entitybankaccounts, @@ -138,7 +140,10 @@ function buildFieldUpdateHistoryText(resource: string, label: string, oldValue: return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"` } -function applyResourceWhereFilters(resource: string, table: any, whereCond: any) { +function applyResourceWhereFilters(resource: string, table: any, whereCond: any, list = false) { + if (list && table === authProfiles) { + return and(whereCond, eq(authProfiles.active, true), isNull(authProfiles.archived_at)) + } if (resource === "members") { return and(whereCond, eq(table.type, "Mitglied")) } @@ -570,7 +575,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const tenantColumn = getTenantColumn(resource, table) let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined - whereCond = applyResourceWhereFilters(resource, table, whereCond) + whereCond = applyResourceWhereFilters(resource, table, whereCond, true) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) let q = server.db.select().from(table).$dynamic() @@ -688,7 +693,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const tenantColumn = getTenantColumn(resource, table); let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined; - whereCond = applyResourceWhereFilters(resource, table, whereCond) + whereCond = applyResourceWhereFilters(resource, table, whereCond, true) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); const debugSearchColumnNames: string[] = [...(config.searchColumns || [])]; @@ -789,7 +794,7 @@ export default async function resourceRoutes(server: FastifyInstance) { }); } let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined - distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond) + distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond, true) distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId) if (search) { @@ -878,7 +883,7 @@ export default async function resourceRoutes(server: FastifyInstance) { } const table = resourceConfig[resource].table - let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) + let whereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)) whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) @@ -943,6 +948,15 @@ export default async function resourceRoutes(server: FastifyInstance) { const table = config.table; let createData: Record = { ...body, tenant: req.user.tenant_id, archived: false }; + if (table === authProfiles) { + delete createData.archived_at; + createData.tenant_id = req.user.tenant_id; + if (createData.user_id) { + const [owner] = await server.db.select({ id: authUsers.id }).from(authUsers) + .where(and(eq(authUsers.id, createData.user_id), isNull(authUsers.archived_at))).limit(1); + if (!owner) return reply.code(409).send({ error: "Aktiver Benutzer nicht gefunden." }); + } + } if (resource === "projects") { const error = projectChatSettingError(req, createData) if (error) return reply.code(error.statusCode).send({ error: error.error }) @@ -1093,7 +1107,7 @@ export default async function resourceRoutes(server: FastifyInstance) { const [oldRecord] = await server.db .select() .from(table) - .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId)) + .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)), portalCustomerId)) .limit(1) if (!oldRecord) { @@ -1105,7 +1119,13 @@ export default async function resourceRoutes(server: FastifyInstance) { if (error) return reply.code(error.statusCode).send({ error: error.error }) } + if (table === authProfiles && oldRecord.archived_at) { + return reply.code(409).send({ error: "Archivierte Profile können nicht mehr bearbeitet werden." }) + } let data: Record = { ...body } + if (table === authProfiles) { + for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete data[key] + } delete data.updatedAt delete data.updated_at delete data.updatedBy @@ -1194,7 +1214,8 @@ export default async function resourceRoutes(server: FastifyInstance) { } }) - let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) + let updateWhereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)) + if (table === authProfiles) updateWhereCond = and(updateWhereCond, isNull(authProfiles.archived_at)) updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond) const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning() @@ -1203,7 +1224,7 @@ export default async function resourceRoutes(server: FastifyInstance) { .update(table) .set({ defaultMandate: false }) .where(and( - eq(table.tenant, tenantId), + eq(getTenantColumn(resource, table), tenantId), eq(table.customer, updated.customer), sql`${table.id} <> ${updated.id}` )) diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index d9b913a5..8681272a 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -11,7 +11,7 @@ import { m2mApiKeys } from "../../db/schema" -import {and, desc, eq, inArray} from "drizzle-orm" +import {and, desc, eq, inArray, isNull} from "drizzle-orm" import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithTeams } from "../utils/profileTeams" import { rotateRefreshToken } from "../utils/authTokens" @@ -152,7 +152,7 @@ export default async function tenantRoutes(server: FastifyInstance) { const users = await server.db .select() .from(authUsers) - .where(inArray(authUsers.id, userIds)) + .where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at))) // 3) auth_profiles pro Tenant laden const profileRows = await server.db @@ -161,7 +161,9 @@ export default async function tenantRoutes(server: FastifyInstance) { .where( and( eq(authProfiles.tenant_id, tenantId), - inArray(authProfiles.user_id, userIds) + inArray(authProfiles.user_id, userIds), + eq(authProfiles.active, true), + isNull(authProfiles.archived_at) )) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) @@ -194,10 +196,14 @@ export default async function tenantRoutes(server: FastifyInstance) { const tenantId = req.user?.tenant_id if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) + const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true" const profileRows = await server.db .select() .from(authProfiles) - .where(eq(authProfiles.tenant_id, tenantId)) + .where(and( + eq(authProfiles.tenant_id, tenantId), + ...(includeArchived ? [] : [eq(authProfiles.active, true), isNull(authProfiles.archived_at)]) + )) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const data = await enrichProfilesWithTeams(server, profilesWithBranches) diff --git a/backend/src/utils/authTokens.ts b/backend/src/utils/authTokens.ts index 16ad5ebe..4876d03d 100644 --- a/backend/src/utils/authTokens.ts +++ b/backend/src/utils/authTokens.ts @@ -70,7 +70,7 @@ export async function rotateRefreshToken( const [user] = await server.db .select({ id: authUsers.id, email: authUsers.email }) .from(authUsers) - .where(eq(authUsers.id, session.userId)) + .where(and(eq(authUsers.id, session.userId), isNull(authUsers.archived_at))) .limit(1) if (!user) return null diff --git a/backend/src/utils/calendarSubscription.ts b/backend/src/utils/calendarSubscription.ts index d388f56d..2d0fb198 100644 --- a/backend/src/utils/calendarSubscription.ts +++ b/backend/src/utils/calendarSubscription.ts @@ -1,5 +1,5 @@ import crypto from "crypto" -import { and, asc, eq, sql } from "drizzle-orm" +import { and, asc, eq, isNull, sql } from "drizzle-orm" import { FastifyInstance } from "fastify" import { authProfiles, events } from "../../db/schema" @@ -105,7 +105,8 @@ export async function generateProfileCalendarSubscriptionToken(server: FastifyIn .where( and( eq(authProfiles.id, profileId), - eq(authProfiles.tenant_id, tenantId) + eq(authProfiles.tenant_id, tenantId), + isNull(authProfiles.archived_at) ) ) .returning() diff --git a/backend/src/utils/requireSystemAdmin.ts b/backend/src/utils/requireSystemAdmin.ts index 2c322e51..6679216e 100644 --- a/backend/src/utils/requireSystemAdmin.ts +++ b/backend/src/utils/requireSystemAdmin.ts @@ -1,5 +1,5 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { authUsers } from "../../db/schema/auth_users"; export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) { @@ -9,7 +9,7 @@ export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRe } // Read the current global role; tenant roles and stale JWT claims do not grant access. const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin }) - .from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1); + .from(authUsers).where(and(eq(authUsers.id, req.user.user_id), isNull(authUsers.archived_at))).limit(1); if (!user?.is_admin) { reply.code(403).send({ error: "Admin access required" }); return null; diff --git a/backend/tests/helpers/collaboration-db.ts b/backend/tests/helpers/collaboration-db.ts index 013c831e..76b79a85 100644 --- a/backend/tests/helpers/collaboration-db.ts +++ b/backend/tests/helpers/collaboration-db.ts @@ -24,7 +24,7 @@ export async function collaborationDatabase(migrations: string[] = []) { const columns = Object.values(getTableColumns(table)).map((column: any) => { let suffix = column.primary ? " PRIMARY KEY" : "" if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()" - if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()" + if (column.columnType === "PgTimestamp" && column.hasDefault) suffix += " DEFAULT now()" if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}` return `"${column.name}" ${column.getSQLType()}${suffix}` }) diff --git a/backend/tests/mcpDocuments.test.ts b/backend/tests/mcpDocuments.test.ts index 1ccb7d9e..b3739407 100644 --- a/backend/tests/mcpDocuments.test.ts +++ b/backend/tests/mcpDocuments.test.ts @@ -151,6 +151,7 @@ test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without e return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] } } if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] } + if (sql.includes('from "auth_users"')) return { rows: [[admin]] } if (sql.includes('from "tenants"')) return { rows: [[null, null]] } if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] } if (sql.includes('from "auth_role_permissions"')) return { rows: [] } diff --git a/backend/tests/userArchive.test.ts b/backend/tests/userArchive.test.ts new file mode 100644 index 00000000..019bec6f --- /dev/null +++ b/backend/tests/userArchive.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import cookie from "@fastify/cookie" +import bcrypt from "bcrypt" +import jwt from "jsonwebtoken" +import { drizzle } from "drizzle-orm/pg-proxy" +import { Pool } from "pg" +import { createHash } from "node:crypto" +import authRoutes from "../src/routes/auth/auth" +import userRoutes from "../src/routes/auth/user" +import authPlugin from "../src/plugins/auth" +import m2mAuthPlugin from "../src/plugins/auth.m2m" +import tenantRoutes from "../src/routes/tenant" +import internalTenantRoutes from "../src/routes/internal/tenant" +import profileRoutes from "../src/routes/profiles" +import resourceRoutes from "../src/routes/resources/main" +import { secrets } from "../src/utils/secrets" + +const adminId = "00000000-0000-4000-8000-000000000001" +const userId = "00000000-0000-4000-8000-000000000002" +const profileId = "00000000-0000-4000-8000-000000000003" +const otherProfileId = "00000000-0000-4000-8000-000000000004" +const passwordHash = bcrypt.hashSync("archive-test-password", 4) +type Row = Record + +// Execute the generated SQL against a small fixture, including its archive filters. +// This keeps the HTTP/authentication tests independent of a configured production DB. +function matches(where: string, params: any[], row: Row) { + for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" = \$(\d+)/g)) { + if (String(row[match[1]]) !== String(params[Number(match[2]) - 1])) return false + } + for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" is null/g)) { + if (row[match[1]] != null) return false + } + for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" in \(([^)]+)\)/g)) { + const values = [...match[2].matchAll(/\$(\d+)/g)].map(value => params[Number(value[1]) - 1]) + if (!values.includes(row[match[1]])) return false + } + return true +} + +function selected(columns: string, row: Row) { + return columns.split(", ").map(column => { + const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1] + return row[name!] ?? null + }) +} + +async function fixture(t: any) { + const data: Record = { + auth_users: [ + { id: adminId, email: "admin@example.test", is_admin: true, archived_at: null }, + { id: userId, email: "former@example.test", password_hash: passwordHash, is_admin: false, archived_at: null }, + ], + auth_profiles: [ + { id: profileId, user_id: userId, tenant_id: 7, active: true, archived_at: null, first_name: "Former", last_name: "User", full_name: "Former User", token_id: "rfid", calendar_subscription_token: "calendar" }, + { id: otherProfileId, user_id: userId, tenant_id: 8, active: false, archived_at: null }, + { id: adminId, user_id: adminId, tenant_id: 7, active: true, archived_at: null }, + ], + auth_tenant_users: [{ tenant_id: 7, user_id: userId }, { tenant_id: 8, user_id: userId }, { tenant_id: 7, user_id: adminId }], + auth_refresh_tokens: [{ id: userId, user_id: userId, tenant_id: 7, token_hash: "old", revoked_at: null }], + m2m_api_keys: [{ id: userId, user_id: userId, tenant_id: 7, active: true, key_hash: createHash("sha256").update("fedeo_mcp_archive_test").digest("hex") }], + tenants: [{ id: 7, name: "First" }, { id: 8, name: "Second" }], + } + const queries: string[] = [] + let inTransaction = false + const db = drizzle(async (sql, params) => { + queries.push(sql) + if (sql.startsWith("select ")) { + const table = sql.match(/from "([^"]+)"/)![1] + const source = table === "m2m_api_keys" + ? data[table].map(row => ({ ...row, ...data.auth_users.find(user => user.id === row.user_id) })) + : data[table] || [] + const rows = source.filter(row => matches(sql.split(" where ")[1] || "", params, row)) + if (sql.startsWith("select count(")) return { rows: [[rows.length]] } + return { rows: rows.map(row => selected(sql.slice(7, sql.indexOf(" from ")), row)) } + } + if (sql.startsWith("update ")) { + const table = sql.match(/update "([^"]+)"/)![1] + const rows = (data[table] || []).filter(row => matches(sql.split(" where ")[1] || "", params, row)) + if (sql.includes('"archived_at" =')) assert.ok(inTransaction, "Archiving must be atomic") + const assignments = sql.slice(sql.indexOf(" set "), sql.indexOf(" where ")) + for (const row of rows) { + for (const match of assignments.matchAll(/"(\w+)" = \$(\d+)/g)) row[match[1]] = params[Number(match[2]) - 1] + } + return { rows: sql.includes(" returning ") ? rows.map(row => selected(sql.split(" returning ")[1], row)) : [] } + } + throw new Error(`Unexpected mutation: ${sql}`) + }) + ;(db as any).transaction = async (fn: any) => { + inTransaction = true + try { return await fn(db) } finally { inTransaction = false } + } + const app = Fastify() + app.decorate("db", db as any) + app.addHook("preHandler", async (req: any) => { + req.user = { user_id: req.headers["x-test-user"] || adminId, tenant_id: 7 } + req.queryConfig = { pagination: { limit: 100, offset: 0 }, filters: {}, sort: [] } + }) + // adminRoutes imports the export utility, whose legacy DB module probes on import. + const queryMock = t.mock.method(Pool.prototype, "query", async () => ({ rows: [{ now: "test" }] })) + const { default: adminRoutes } = await import("../src/routes/admin") + queryMock.mock.restore() + await app.register(adminRoutes) + await app.register(tenantRoutes) + await app.register(profileRoutes) + await app.register(userRoutes) + await app.register(resourceRoutes) + await app.register(internalTenantRoutes, { prefix: "/internal" }) + t.after(() => app.close()) + const archive = (id = userId, caller = adminId) => app.inject({ method: "POST", url: `/admin/users/${id}/archive`, headers: { "x-test-user": caller } }) + return { app, db, data, queries, archive } +} + +test("archiving preserves history, archives every tenant profile and revokes credentials atomically", async t => { + const f = await fixture(t) + const memberships = structuredClone(f.data.auth_tenant_users) + const response = await f.archive() + assert.equal(response.statusCode, 200, response.body) + const archivedAt = response.json().user.archived_at + assert.ok(archivedAt) + for (const profile of f.data.auth_profiles.filter(profile => profile.user_id === userId)) { + assert.equal(profile.active, false) + assert.equal(profile.archived_at, archivedAt) + assert.equal(profile.token_id, null) + assert.equal(profile.calendar_subscription_token, null) + } + assert.equal(f.data.auth_profiles[2].active, true) + assert.equal(f.data.auth_users[0].archived_at, null) + assert.equal(f.data.auth_refresh_tokens[0].revoked_at, archivedAt) + assert.equal(f.data.m2m_api_keys[0].active, false) + assert.deepEqual(f.data.auth_tenant_users, memberships) + assert.equal((await f.archive()).json().user.archived_at, archivedAt) + assert.ok(f.queries.some(sql => sql.endsWith("for update"))) +}) + +test("only an active system admin can archive another existing user", async t => { + const f = await fixture(t) + assert.equal((await f.archive(adminId, userId)).statusCode, 403) + assert.equal((await f.archive(adminId)).statusCode, 409) + assert.equal((await f.archive(profileId)).statusCode, 404) + assert.equal((await f.archive("invalid-id")).statusCode, 400) + f.data.auth_users[0].archived_at = new Date().toISOString() + assert.equal((await f.archive()).statusCode, 403) + assert.equal(f.data.auth_users[1].archived_at, null) +}) + +test("archived users disappear from active administration and tenant lists but retain a readable archive", async t => { + const f = await fixture(t) + await f.archive() + const overview = (await f.app.inject("/admin/overview")).json() + assert.deepEqual(overview.users.map((row: Row) => row.id), [adminId]) + assert.equal(overview.tenants.find((row: Row) => row.id === 7).user_count, 1) + const archived = (await f.app.inject("/admin/overview?includeArchived=true")).json().users.find((row: Row) => row.id === userId) + assert.ok(archived.archived_at) + assert.equal(archived.profiles.length, 2) + for (const url of ["/tenant/users", "/internal/tenant/users"]) { + const response = await f.app.inject(url) + assert.equal(response.statusCode, 200, response.body) + assert.deepEqual(response.json().users.map((row: Row) => row.id), [adminId]) + } + for (const url of ["/tenant/profiles", "/internal/tenant/7/profiles", "/resource/profiles", "/resource/authProfile", "/resource/profiles/paginated"]) { + const response = await f.app.inject(url) + assert.equal(response.statusCode, 200, response.body) + const body = response.json() + assert.deepEqual((body.data || body).map((row: Row) => row.id), [adminId], url) + } + const historic = (await f.app.inject("/tenant/profiles?includeArchived=true")).json().data + assert.equal(historic.find((row: Row) => row.id === profileId).full_name, "Former User") + const detail = await f.app.inject(`/resource/profiles/${profileId}`) + assert.equal(detail.statusCode, 200, detail.body) + assert.equal(detail.json().full_name, "Former User") +}) + +test("archived accounts and profiles cannot be edited, reassigned or reactivated", async t => { + const f = await fixture(t) + await f.archive() + for (const [method, url, payload, status] of [ + ["PUT", `/admin/users/${userId}`, { archived_at: null, is_admin: true }, 409], + ["PUT", `/admin/users/${userId}/access`, { tenant_ids: [9] }, 409], + ["POST", `/admin/users/${userId}/reset-password`, {}, 409], + ["POST", "/admin/add-user-to-tenant", { user_id: userId, tenant_id: 9 }, 404], + ["PUT", `/user/${userId}/profile`, { data: { active: true, archived_at: null, user_id: adminId } }, 404], + ["PUT", `/profiles/${profileId}`, { active: true, archived_at: null }, 404], + ["PUT", `/resource/profiles/${profileId}`, { active: true, archived_at: null, user_id: adminId }, 409], + ["POST", `/profiles/${profileId}/calendar-subscription-token`, {}, 404], + ["POST", "/resource/profiles", { user_id: userId, first_name: "New", last_name: "Profile" }, 409], + ] as const) { + const response = await f.app.inject({ method, url, payload }) + assert.equal(response.statusCode, status, `${url}: ${response.body}`) + } + assert.equal(f.data.auth_profiles[0].active, false) + assert.equal(f.data.auth_profiles[0].user_id, userId) +}) + +test("archiving blocks password login, refresh, existing JWTs and legacy API keys", async t => { + const f = await fixture(t) + const oldSecret = secrets.JWT_SECRET, oldM2mKey = secrets.M2M_API_KEY + secrets.JWT_SECRET = "archive-test-secret" + secrets.M2M_API_KEY = "archive-test-legacy-key" + t.after(() => { secrets.JWT_SECRET = oldSecret; secrets.M2M_API_KEY = oldM2mKey }) + const app = Fastify() + app.decorate("db", f.db as any) + await app.register(cookie) + await app.register(authRoutes) + await app.register(async protectedApp => { + await protectedApp.register(authPlugin) + protectedApp.get("/check", async () => ({ success: true })) + protectedApp.get("/mcp", async () => ({ success: true })) + }, { prefix: "/api" }) + await app.register(async internal => { + await internal.register(m2mAuthPlugin) + internal.get("/check", async () => ({ success: true })) + }, { prefix: "/internal" }) + t.after(() => app.close()) + const token = jwt.sign({ user_id: userId, tenant_id: null, is_admin: true }, secrets.JWT_SECRET) + const headers = { authorization: `Bearer ${token}` } + assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 200) + await f.archive() + assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 401) + assert.equal((await app.inject({ method: "POST", url: "/auth/login", payload: { email: "former@example.test", password: "archive-test-password" } })).statusCode, 401) + assert.equal((await app.inject({ method: "POST", url: "/auth/password/reset", payload: { email: "former@example.test" } })).statusCode, 404) + // Even a session issued concurrently with archiving must fail the current-user check. + f.data.auth_refresh_tokens[0].revoked_at = null + const { hashRefreshToken } = await import("../src/utils/authTokens") + f.data.auth_refresh_tokens[0].token_hash = hashRefreshToken("old-refresh-token") + assert.equal((await app.inject({ method: "POST", url: "/auth/refresh", payload: { refreshToken: "old-refresh-token" } })).statusCode, 401) + f.data.m2m_api_keys[0].active = true + assert.equal((await app.inject({ url: "/api/mcp", headers: { authorization: "Bearer fedeo_mcp_archive_test" } })).statusCode, 401) + assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": "fedeo_mcp_archive_test" } })).statusCode, 401) + assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": secrets.M2M_API_KEY, "x-user-id": userId, "x-tenant-id": "7" } })).statusCode, 401) +}) diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index 53e72c45..78a86483 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -23,11 +23,13 @@ export type AdminUserProfile = { last_name: string email?: string | null active: boolean + archived_at: string | null } export type AdminUser = { id: string email: string + archived_at: string | null display_name: string multiTenant: boolean must_change_password: boolean @@ -161,8 +163,8 @@ export type CreateInstanceAgentResult = { export const useAdmin = () => { const { $api } = useNuxtApp() - const getOverview = async (): Promise => { - const response = await $api("/api/admin/overview") as any + const getOverview = async (includeArchived = false): Promise => { + const response = await $api("/api/admin/overview", { query: { includeArchived } }) as any return { users: response?.users || [], @@ -193,6 +195,10 @@ export const useAdmin = () => { }) } + const archiveUser = async (id: string) => { + return await $api(`/api/admin/users/${id}/archive`, { method: "POST" }) + } + const resetUserPassword = async (id: string, password?: string) => { return await $api(`/api/admin/users/${id}/reset-password`, { method: "POST", @@ -294,6 +300,7 @@ export const useAdmin = () => { createUserForProfile, updateUser, resetUserPassword, + archiveUser, updateUserAccess, createTenant, invitePortalUser, diff --git a/frontend/pages/administration/users/[id].vue b/frontend/pages/administration/users/[id].vue index e5b38751..4bb66eef 100644 --- a/frontend/pages/administration/users/[id].vue +++ b/frontend/pages/administration/users/[id].vue @@ -11,6 +11,10 @@ const userId = route.params.id as string const loading = ref(true) const saving = ref(false) const resettingPassword = ref(false) +const archiving = ref(false) +const archiveModalOpen = ref(false) +const profileStore = useProfileStore() +const isOwnUser = computed(() => userId === (auth.user?.id || auth.user?.user_id)) const resetPasswordModalOpen = ref(false) const resetPasswordInput = ref("") const resetPasswordResult = ref("") @@ -102,7 +106,7 @@ const fetchUser = async () => { loading.value = true try { - const overview = await admin.getOverview() + const overview = await admin.getOverview(true) roles.value = overview.roles tenants.value = overview.tenants unassignedProfiles.value = overview.unassignedProfiles @@ -136,7 +140,7 @@ const fetchUser = async () => { } const saveUser = async () => { - if (!userForm.value || saving.value) return + if (!userForm.value || userForm.value.archived_at || saving.value || archiving.value) return saving.value = true normalizeUserAssignments() @@ -172,8 +176,24 @@ const saveUser = async () => { } } +const archiveUser = async () => { + if (!userForm.value || userForm.value.archived_at || archiving.value || isOwnUser.value) return + archiving.value = true + try { + await admin.archiveUser(userForm.value.id) + archiveModalOpen.value = false + await fetchUser() + if (auth.activeTenant) await profileStore.fetchData() + toast.add({ title: "Benutzer dauerhaft archiviert", description: "Alle zugehörigen Profile wurden archiviert und ausgeblendet.", color: "success" }) + } catch (err: any) { + toast.add({ title: "Benutzer konnte nicht archiviert werden", description: err?.data?.error || err?.message || "Unbekannter Fehler", color: "error" }) + } finally { + archiving.value = false + } +} + const resetPassword = async () => { - if (!userForm.value || resettingPassword.value) return + if (!userForm.value || userForm.value.archived_at || resettingPassword.value || archiving.value) return const password = resetPasswordInput.value.trim() if (password && password.length < 8) { @@ -248,21 +268,40 @@ onMounted(async () => { +
@@ -273,7 +312,7 @@ onMounted(async () => { - + @@ -341,6 +380,7 @@ onMounted(async () => { { - {{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }} + {{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}{{ profile.archived_at ? " · Archiviert" : "" }}
@@ -398,6 +439,27 @@ onMounted(async () => { + + + +