feat(admin): archive users and their profiles permanently
This commit is contained in:
3
backend/db/migrations/0082_archive_users.sql
Normal file
3
backend/db/migrations/0082_archive_users.sql
Normal file
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 {
|
||||
// Check current account state even when a JWT carries cached admin claims.
|
||||
const [currentUser] = await server.db
|
||||
.select({
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.select({ is_admin: authUsers.is_admin })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, payload.user_id))
|
||||
.where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at)))
|
||||
.limit(1)
|
||||
|
||||
req.user.is_admin = Boolean(currentUser?.is_admin)
|
||||
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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
// ----------------------------
|
||||
|
||||
51
backend/src/routes/adminUserArchive.ts
Normal file
51
backend/src/routes/adminUserArchive.ts
Normal file
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, any> = { ...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<string, any> = { ...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}`
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
233
backend/tests/userArchive.test.ts
Normal file
233
backend/tests/userArchive.test.ts
Normal file
@@ -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<string, any>
|
||||
|
||||
// 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<string, Row[]> = {
|
||||
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)
|
||||
})
|
||||
@@ -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<AdminOverview> => {
|
||||
const response = await $api("/api/admin/overview") as any
|
||||
const getOverview = async (includeArchived = false): Promise<AdminOverview> => {
|
||||
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,
|
||||
|
||||
@@ -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 () => {
|
||||
</UButton>
|
||||
</template>
|
||||
<template #right>
|
||||
<UButton
|
||||
v-if="userForm && !userForm.archived_at && !isOwnUser"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-heroicons-archive-box"
|
||||
:disabled="saving || resettingPassword || archiving"
|
||||
@click="archiveModalOpen = true"
|
||||
>
|
||||
Dauerhaft archivieren
|
||||
</UButton>
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-heroicons-key"
|
||||
:disabled="loading || !userForm || !!userForm.archived_at || archiving || saving"
|
||||
@click="openResetPasswordModal"
|
||||
>
|
||||
Passwort zurücksetzen
|
||||
</UButton>
|
||||
<UButton color="primary" :loading="saving" @click="saveUser">
|
||||
<UButton color="primary" :loading="saving" :disabled="loading || !userForm || !!userForm.archived_at || archiving || resettingPassword" @click="saveUser">
|
||||
Speichern
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent>
|
||||
<UAlert
|
||||
v-if="userForm?.archived_at"
|
||||
title="Benutzer dauerhaft archiviert"
|
||||
:description="`Archiviert am ${new Date(userForm.archived_at).toLocaleString('de-DE')}. Die Anmeldung ist gesperrt. Alle zugehörigen Profile sind archiviert und ausgeblendet.`"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
class="mb-4"
|
||||
/>
|
||||
<UCard v-if="!loading && userForm">
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
@@ -273,7 +312,7 @@ onMounted(async () => {
|
||||
|
||||
<USeparator label="Benutzer" />
|
||||
|
||||
<UForm :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UForm :disabled="!!userForm.archived_at || archiving" :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UFormField label="E-Mail">
|
||||
<UInput v-model="userForm.email" />
|
||||
</UFormField>
|
||||
@@ -341,6 +380,7 @@ onMounted(async () => {
|
||||
|
||||
<UFormField label="Rolle">
|
||||
<USelectMenu
|
||||
:disabled="!!userForm.archived_at || archiving"
|
||||
:model-value="getRoleForTenant(tenantId)"
|
||||
:items="getRoleOptionsForTenant(tenantId)"
|
||||
value-key="value"
|
||||
@@ -352,6 +392,7 @@ onMounted(async () => {
|
||||
|
||||
<UFormField label="Freies Profil">
|
||||
<USelectMenu
|
||||
:disabled="!!userForm.archived_at || archiving"
|
||||
:model-value="getProfileAssignmentForTenant(tenantId)"
|
||||
:items="[
|
||||
{ label: 'Neues Profil erzeugen', value: null },
|
||||
@@ -390,7 +431,7 @@ onMounted(async () => {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
>
|
||||
{{ 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" : "" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</UCard>
|
||||
@@ -398,6 +439,27 @@ onMounted(async () => {
|
||||
<USkeleton v-if="loading" class="h-80" />
|
||||
</UDashboardPanelContent>
|
||||
|
||||
<UModal v-model:open="archiveModalOpen" :dismissible="!archiving">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="text-lg font-semibold">Benutzer dauerhaft archivieren</div>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<p><strong>{{ userForm?.display_name }}</strong> ({{ userForm?.email }}) wird dauerhaft archiviert.</p>
|
||||
<p>Die Anmeldung wird gesperrt, bestehende Sitzungen und API-Schlüssel werden ungültig. Alle {{ userForm?.profiles.length || 0 }} zugehörigen Profile werden in sämtlichen Tenants archiviert und aus Listen und Auswahllisten ausgeblendet.</p>
|
||||
<p>Bestehende Dokumente, Zeiterfassungen und Zuordnungen bleiben erhalten. Die Archivierung kann nicht rückgängig gemacht werden.</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton variant="soft" color="neutral" :disabled="archiving" @click="archiveModalOpen = false">Abbrechen</UButton>
|
||||
<UButton color="error" icon="i-heroicons-archive-box" :loading="archiving" @click="archiveUser">Dauerhaft archivieren</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="resetPasswordModalOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
|
||||
@@ -12,6 +12,7 @@ const createUserModalOpen = ref(false)
|
||||
const createdUserPassword = ref("")
|
||||
const users = ref<AdminUser[]>([])
|
||||
const searchString = ref("")
|
||||
const showArchived = ref(false)
|
||||
|
||||
const createUserForm = ref({
|
||||
email: "",
|
||||
@@ -27,13 +28,15 @@ const templateColumns = [
|
||||
{ key: "email", label: "E-Mail" },
|
||||
{ key: "tenant_count", label: "Tenants" },
|
||||
{ key: "is_admin", label: "Admin" },
|
||||
{ key: "status", label: "Status" },
|
||||
]
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const search = searchString.value.trim().toLowerCase()
|
||||
const rows = users.value.map((user) => ({
|
||||
const rows = users.value.filter((user) => showArchived.value || !user.archived_at).map((user) => ({
|
||||
...user,
|
||||
tenant_count: user.tenant_ids.length,
|
||||
status: user.archived_at ? "Archiviert" : "Aktiv",
|
||||
is_admin: user.is_admin ? "Ja" : "Nein",
|
||||
}))
|
||||
|
||||
@@ -50,7 +53,7 @@ const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const overview = await admin.getOverview()
|
||||
const overview = await admin.getOverview(true)
|
||||
users.value = overview.users
|
||||
} catch (err: any) {
|
||||
console.error("[administration/users/index]", err)
|
||||
@@ -119,6 +122,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<UDashboardNavbar title="Administration: Benutzer" :badge="filteredRows.length">
|
||||
<template #right>
|
||||
<UCheckbox v-model="showArchived" label="Archivierte anzeigen" />
|
||||
<UInput
|
||||
v-model="searchString"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
})
|
||||
|
||||
const profiles = ref([])
|
||||
const allProfiles = ref([])
|
||||
const ownProfiles = ref([])
|
||||
const activeProfile = ref(null)
|
||||
const tenants = ref([])
|
||||
@@ -60,8 +61,8 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
await fetchOwnProfiles()
|
||||
await fetchProfiles()
|
||||
await fetchOwnProfiles()
|
||||
await fetchTenants()
|
||||
await fetchOwnTenant()
|
||||
loaded.value = true
|
||||
@@ -71,6 +72,7 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
loaded.value = false
|
||||
ownTenant.value = {}
|
||||
profiles.value = []
|
||||
allProfiles.value = []
|
||||
ownProfiles.value = []
|
||||
tenants.value = []
|
||||
}
|
||||
@@ -92,10 +94,13 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
|
||||
async function fetchProfiles() {
|
||||
try {
|
||||
const res = await useNuxtApp().$api("/api/tenant/profiles")
|
||||
profiles.value = res?.data || []
|
||||
const res = await useNuxtApp().$api("/api/tenant/profiles", { query: { includeArchived: true } })
|
||||
// Retain historical names while keeping archived profiles out of selections.
|
||||
allProfiles.value = res?.data || []
|
||||
profiles.value = allProfiles.value.filter(profile => profile.active && !profile.archived_at)
|
||||
} catch (e) {
|
||||
profiles.value = activeProfile.value ? [activeProfile.value] : []
|
||||
allProfiles.value = profiles.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +117,8 @@ export const useProfileStore = defineStore("profile", () => {
|
||||
})
|
||||
|
||||
const getProfileById = computed(() => (itemId) => {
|
||||
return profiles.value.find((item) => item.id === itemId || item.user_id === itemId)
|
||||
const profile = allProfiles.value.find((item) => item.id === itemId || item.user_id === itemId)
|
||||
return profile ? { ...profile, fullName: profile.full_name || profile.fullName } : { fullName: "Unbekanntes Profil" }
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user