feat(admin): archive users and their profiles permanently
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m53s

This commit is contained in:
2026-10-15 14:22:12 +02:00
parent 5dc9e686fc
commit 4ffafef3f1
26 changed files with 519 additions and 75 deletions

View File

@@ -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]) {

View File

@@ -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) {

View File

@@ -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";
// ----------------------------

View 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 }
})
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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])

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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}`
))

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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;