From 7e0a2f5e4f1931917f9f26bfa49f190521be244d Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 18 Mar 2026 18:34:02 +0100 Subject: [PATCH] New Admin Dashboard --- backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/auth_users.ts | 1 + backend/src/plugins/auth.ts | 19 + backend/src/routes/admin.ts | 682 ++++++++++++++++++++++- backend/src/routes/auth/me.ts | 1 + frontend/components/MainNav.vue | 6 + frontend/stores/auth.ts | 10 +- 7 files changed, 720 insertions(+), 6 deletions(-) diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 35a1b8e..0b58561 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1773572400000, "tag": "0020_file_extracted_text", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1773835200000, + "tag": "0021_admin_user_flag", + "breakpoints": true } ] } diff --git a/backend/db/schema/auth_users.ts b/backend/db/schema/auth_users.ts index 224bd74..0bec516 100644 --- a/backend/db/schema/auth_users.ts +++ b/backend/db/schema/auth_users.ts @@ -12,6 +12,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), updatedAt: timestamp("updated_at", { withTimezone: true }), diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index f4b1427..1f8e204 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -6,6 +6,7 @@ import { secrets } from "../utils/secrets" import { authUserRoles, authRolePermissions, + authUsers, } from "../../db/schema" import { eq, and } from "drizzle-orm" @@ -43,6 +44,16 @@ export default fp(async (server: FastifyInstance) => { // Payload an Request hängen req.user = payload + 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) + // Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung if (!req.user.tenant_id) { return @@ -66,6 +77,13 @@ export default fp(async (server: FastifyInstance) => { .limit(1) if (roleRows.length === 0) { + if (req.user.is_admin) { + req.role = "" + req.permissions = [] + req.hasPermission = () => false + return + } + return reply .code(403) .send({ error: "No role assigned for this tenant" }) @@ -107,6 +125,7 @@ declare module "fastify" { user_id: string email: string tenant_id: number | null + is_admin?: boolean } role: string permissions: string[] diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 10c7d53..b133cfb 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,19 +1,689 @@ -import { FastifyInstance } from "fastify"; -import { eq } from "drizzle-orm"; +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { authTenantUsers, + authProfiles, + authRoles, + authUserRoles, authUsers, + filetags, + folders, tenants, } from "../../db/schema"; +import { generateRandomPassword, hashPassword } from "../utils/password"; export default async function adminRoutes(server: FastifyInstance) { + const deriveNameFromEmail = (email: string) => { + const localPart = email.split("@")[0] || "Benutzer"; + const normalized = localPart.replace(/[._-]+/g, " ").trim(); + const parts = normalized.split(/\s+/).filter(Boolean); + const firstName = parts[0] + ? parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + : "Neuer"; + const lastName = parts.length > 1 + ? parts.slice(1).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") + : "Benutzer"; + + return { first_name: firstName, last_name: lastName }; + }; + + const createTenantSeeds = async (tenantId: number, createdBy: string) => { + const currentYear = new Date().getFullYear(); + + const insertedTags = await server.db + .insert(filetags) + .values([ + { + tenant: tenantId, + name: "Rechnungen", + color: "#16a34a", + createdDocumentType: "invoices", + }, + { + tenant: tenantId, + name: "Angebote", + color: "#2563eb", + createdDocumentType: "quotes", + }, + { + tenant: tenantId, + name: "Auftragsbestätigungen", + color: "#7c3aed", + createdDocumentType: "confirmationOrders", + }, + { + tenant: tenantId, + name: "Lieferscheine", + color: "#ea580c", + createdDocumentType: "deliveryNotes", + }, + { + tenant: tenantId, + name: "Eingangsrechnungen", + color: "#dc2626", + incomingDocumentType: "invoices", + }, + { + tenant: tenantId, + name: "Mahnungen", + color: "#b91c1c", + incomingDocumentType: "reminders", + }, + ]) + .returning({ + id: filetags.id, + name: filetags.name, + createdDocumentType: filetags.createdDocumentType, + incomingDocumentType: filetags.incomingDocumentType, + }); + + const invoiceTag = insertedTags.find((tag) => tag.createdDocumentType === "invoices"); + const quoteTag = insertedTags.find((tag) => tag.createdDocumentType === "quotes"); + const confirmationTag = insertedTags.find((tag) => tag.createdDocumentType === "confirmationOrders"); + const deliveryTag = insertedTags.find((tag) => tag.createdDocumentType === "deliveryNotes"); + const incomingInvoiceTag = insertedTags.find((tag) => tag.incomingDocumentType === "invoices"); + + await server.db + .insert(folders) + .values([ + { + tenant: tenantId, + name: "Ausgangsrechnungen", + function: "invoices", + year: currentYear, + icon: "i-heroicons-document-text", + standardFiletype: invoiceTag?.id, + standardFiletypeIsOptional: false, + isSystemUsed: true, + updatedAt: new Date(), + updatedBy: createdBy, + }, + { + tenant: tenantId, + name: "Angebote", + function: "quotes", + year: currentYear, + icon: "i-heroicons-document-duplicate", + standardFiletype: quoteTag?.id, + standardFiletypeIsOptional: false, + isSystemUsed: true, + updatedAt: new Date(), + updatedBy: createdBy, + }, + { + tenant: tenantId, + name: "Auftragsbestätigungen", + function: "confirmationOrders", + year: currentYear, + icon: "i-heroicons-clipboard-document-check", + standardFiletype: confirmationTag?.id, + standardFiletypeIsOptional: false, + isSystemUsed: true, + updatedAt: new Date(), + updatedBy: createdBy, + }, + { + tenant: tenantId, + name: "Lieferscheine", + function: "deliveryNotes", + year: currentYear, + icon: "i-heroicons-truck", + standardFiletype: deliveryTag?.id, + standardFiletypeIsOptional: false, + isSystemUsed: true, + updatedAt: new Date(), + updatedBy: createdBy, + }, + { + tenant: tenantId, + name: "Eingangsrechnungen", + function: "incomingInvoices", + year: currentYear, + icon: "i-heroicons-inbox-arrow-down", + standardFiletype: incomingInvoiceTag?.id, + standardFiletypeIsOptional: false, + isSystemUsed: true, + updatedAt: new Date(), + updatedBy: createdBy, + }, + { + tenant: tenantId, + name: "Belege Bankeinzahlung", + function: "deposit", + year: currentYear, + icon: "i-heroicons-banknotes", + isSystemUsed: true, + updatedAt: new Date(), + updatedBy: createdBy, + }, + ]); + }; + + const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => { + if (!req.user?.user_id) { + reply.code(401).send({ error: "Unauthorized" }); + return null; + } + + const [currentUser] = await server.db + .select({ + id: authUsers.id, + is_admin: authUsers.is_admin, + }) + .from(authUsers) + .where(eq(authUsers.id, req.user.user_id)) + .limit(1); + + if (!currentUser?.is_admin) { + reply.code(403).send({ error: "Admin access required" }); + return null; + } + + return currentUser; + }; + + // ------------------------------------------------------------- + // GET /admin/overview + // ------------------------------------------------------------- + server.get("/admin/overview", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([ + server.db + .select({ + id: tenants.id, + name: tenants.name, + short: tenants.short, + createdAt: tenants.createdAt, + locked: tenants.locked, + }) + .from(tenants), + server.db + .select({ + id: authUsers.id, + email: authUsers.email, + created_at: authUsers.created_at, + multiTenant: authUsers.multiTenant, + must_change_password: authUsers.must_change_password, + is_admin: authUsers.is_admin, + }) + .from(authUsers), + server.db + .select({ + id: authProfiles.id, + user_id: authProfiles.user_id, + tenant_id: authProfiles.tenant_id, + first_name: authProfiles.first_name, + last_name: authProfiles.last_name, + full_name: authProfiles.full_name, + email: authProfiles.email, + active: authProfiles.active, + }) + .from(authProfiles), + server.db + .select() + .from(authTenantUsers), + server.db + .select({ + id: authRoles.id, + name: authRoles.name, + description: authRoles.description, + tenant_id: authRoles.tenant_id, + }) + .from(authRoles), + server.db + .select({ + user_id: authUserRoles.user_id, + role_id: authUserRoles.role_id, + tenant_id: authUserRoles.tenant_id, + }) + .from(authUserRoles), + ]); + + const users = userRows.map((user) => { + const profiles = profileRows.filter((profile) => profile.user_id === user.id); + const memberships = membershipRows.filter((membership) => membership.user_id === user.id); + const roleAssignments = roleAssignmentRows.filter((assignment) => assignment.user_id === user.id); + const preferredProfile = profiles.find((profile) => profile.active) || profiles[0]; + const fallbackName = deriveNameFromEmail(user.email); + + return { + ...user, + display_name: preferredProfile?.full_name || user.email, + profile_defaults: { + first_name: preferredProfile?.first_name || fallbackName.first_name, + last_name: preferredProfile?.last_name || fallbackName.last_name, + }, + profiles, + tenant_ids: memberships.map((membership) => membership.tenant_id), + role_assignments: roleAssignments, + }; + }); + + const tenantsWithCounts = tenantRows.map((tenant) => ({ + ...tenant, + user_count: membershipRows.filter((membership) => membership.tenant_id === tenant.id).length, + })); + + return { + users, + tenants: tenantsWithCounts, + roles: roleRows, + unassignedProfiles: profileRows.filter((profile) => !profile.user_id), + memberships: membershipRows, + roleAssignments: roleAssignmentRows, + }; + } catch (err) { + console.error("ERROR /admin/overview:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // POST /admin/users + // ------------------------------------------------------------- + server.post("/admin/users", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const body = req.body as { + email?: string; + password?: string; + is_admin?: boolean; + multiTenant?: boolean; + first_name?: string; + last_name?: string; + }; + + const email = body.email?.trim().toLowerCase(); + if (!email) { + return reply.code(400).send({ error: "email required" }); + } + + const existingUsers = await server.db + .select({ id: authUsers.id }) + .from(authUsers) + .where(eq(authUsers.email, email)) + .limit(1); + + if (existingUsers.length) { + return reply.code(409).send({ error: "User with this email already exists" }); + } + + const initialPassword = body.password?.trim() || generateRandomPassword(14); + const passwordHash = await hashPassword(initialPassword); + + const [createdUser] = await server.db + .insert(authUsers) + .values({ + email, + passwordHash, + is_admin: Boolean(body.is_admin), + multiTenant: typeof body.multiTenant === "boolean" ? body.multiTenant : true, + must_change_password: true, + updatedAt: new Date(), + }) + .returning({ + id: authUsers.id, + email: authUsers.email, + created_at: authUsers.created_at, + multiTenant: authUsers.multiTenant, + must_change_password: authUsers.must_change_password, + is_admin: authUsers.is_admin, + }); + + return { + user: createdUser, + initialPassword, + profile_defaults: { + first_name: body.first_name?.trim() || deriveNameFromEmail(email).first_name, + last_name: body.last_name?.trim() || deriveNameFromEmail(email).last_name, + }, + }; + } catch (err) { + console.error("ERROR /admin/users:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // POST /admin/tenants + // ------------------------------------------------------------- + server.post("/admin/tenants", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const body = req.body as { + name?: string; + short?: string; + }; + + const name = body.name?.trim(); + const short = body.short?.trim(); + + if (!name || !short) { + return reply.code(400).send({ error: "name and short required" }); + } + + const [createdTenant] = await server.db + .insert(tenants) + .values({ + name, + short, + updatedAt: new Date(), + updatedBy: currentUser.id, + }) + .returning({ + id: tenants.id, + name: tenants.name, + short: tenants.short, + createdAt: tenants.createdAt, + locked: tenants.locked, + }); + + await createTenantSeeds(createdTenant.id, currentUser.id); + + return { tenant: createdTenant }; + } catch (err) { + console.error("ERROR /admin/tenants:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // PUT /admin/users/:user_id + // ------------------------------------------------------------- + server.put("/admin/users/:user_id", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { user_id } = req.params as { user_id: string }; + const body = req.body as { + email?: string; + multiTenant?: boolean; + must_change_password?: boolean; + is_admin?: boolean; + }; + + const updateData: Record = { + updatedAt: new Date(), + }; + + if (typeof body.email === "string") updateData.email = body.email.trim().toLowerCase(); + if (typeof body.multiTenant === "boolean") updateData.multiTenant = body.multiTenant; + if (typeof body.must_change_password === "boolean") updateData.must_change_password = body.must_change_password; + if (typeof body.is_admin === "boolean") updateData.is_admin = body.is_admin; + + const [updatedUser] = await server.db + .update(authUsers) + .set(updateData) + .where(eq(authUsers.id, user_id)) + .returning({ + id: authUsers.id, + email: authUsers.email, + created_at: authUsers.created_at, + multiTenant: authUsers.multiTenant, + must_change_password: authUsers.must_change_password, + is_admin: authUsers.is_admin, + }); + + if (!updatedUser) { + return reply.code(404).send({ error: "User not found" }); + } + + return { user: updatedUser }; + } catch (err) { + console.error("ERROR /admin/users/:user_id:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // PUT /admin/tenants/:tenant_id + // ------------------------------------------------------------- + server.put("/admin/tenants/:tenant_id", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { tenant_id } = req.params as { tenant_id: string }; + const body = req.body as { + name?: string; + short?: string; + }; + + const updateData: Record = { + updatedAt: new Date(), + updatedBy: currentUser.id, + }; + + if (typeof body.name === "string") updateData.name = body.name.trim(); + if (typeof body.short === "string") updateData.short = body.short.trim(); + + const [updatedTenant] = await server.db + .update(tenants) + .set(updateData) + .where(eq(tenants.id, Number(tenant_id))) + .returning({ + id: tenants.id, + name: tenants.name, + short: tenants.short, + createdAt: tenants.createdAt, + locked: tenants.locked, + }); + + if (!updatedTenant) { + return reply.code(404).send({ error: "Tenant not found" }); + } + + return { tenant: updatedTenant }; + } catch (err) { + console.error("ERROR /admin/tenants/:tenant_id:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); + + // ------------------------------------------------------------- + // PUT /admin/users/:user_id/access + // ------------------------------------------------------------- + server.put("/admin/users/:user_id/access", async (req, reply) => { + try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + + const { user_id } = req.params as { user_id: string }; + const body = req.body as { + tenant_ids?: number[]; + role_assignments?: { tenant_id: number; role_id: string }[]; + profile_defaults?: { first_name?: string; last_name?: string }; + profile_assignments?: { tenant_id: number; profile_id?: string | null }[]; + }; + + const tenantIds = Array.from(new Set((body.tenant_ids || []).map((tenantId) => Number(tenantId)).filter(Boolean))); + const requestedAssignments = (body.role_assignments || []) + .map((assignment) => ({ + tenant_id: Number(assignment.tenant_id), + role_id: assignment.role_id, + })) + .filter((assignment) => assignment.tenant_id && assignment.role_id && tenantIds.includes(assignment.tenant_id)); + + const [targetUser] = await server.db + .select({ id: authUsers.id, email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, user_id)) + .limit(1); + + if (!targetUser) { + return reply.code(404).send({ error: "User not found" }); + } + + const availableRoles = requestedAssignments.length + ? await server.db + .select({ + id: authRoles.id, + tenant_id: authRoles.tenant_id, + }) + .from(authRoles) + .where( + inArray( + authRoles.id, + requestedAssignments.map((assignment) => assignment.role_id) + ) + ) + : []; + + const validRoleIds = new Set( + availableRoles + .filter((role) => + role.tenant_id === null || + requestedAssignments.some((assignment) => assignment.role_id === role.id && assignment.tenant_id === role.tenant_id) + ) + .map((role) => role.id) + ); + + const validAssignments = requestedAssignments.filter((assignment) => validRoleIds.has(assignment.role_id)); + const existingMemberships = await server.db + .select() + .from(authTenantUsers) + .where(eq(authTenantUsers.user_id, user_id)); + const removedTenantIds = existingMemberships + .map((membership) => membership.tenant_id) + .filter((tenantId) => !tenantIds.includes(tenantId)); + + const existingUserProfiles = await server.db + .select({ + id: authProfiles.id, + tenant_id: authProfiles.tenant_id, + }) + .from(authProfiles) + .where(eq(authProfiles.user_id, user_id)); + + const unassignedProfiles = tenantIds.length + ? await server.db + .select({ + id: authProfiles.id, + tenant_id: authProfiles.tenant_id, + }) + .from(authProfiles) + .where( + and( + inArray(authProfiles.tenant_id, tenantIds), + isNull(authProfiles.user_id) + ) + ) + : []; + + const fallbackName = deriveNameFromEmail(targetUser.email); + const profileDefaults = { + first_name: body.profile_defaults?.first_name?.trim() || fallbackName.first_name, + last_name: body.profile_defaults?.last_name?.trim() || fallbackName.last_name, + }; + const requestedProfileAssignments = new Map( + (body.profile_assignments || []) + .filter((assignment) => assignment?.tenant_id && assignment.profile_id) + .map((assignment) => [Number(assignment.tenant_id), String(assignment.profile_id)]) + ); + + await server.db + .delete(authUserRoles) + .where(eq(authUserRoles.user_id, user_id)); + + await server.db + .delete(authTenantUsers) + .where(eq(authTenantUsers.user_id, user_id)); + + if (tenantIds.length) { + await server.db + .insert(authTenantUsers) + .values( + tenantIds.map((tenantId) => ({ + tenant_id: tenantId, + user_id, + created_by: currentUser.id, + })) + ); + } + + if (validAssignments.length) { + await server.db + .insert(authUserRoles) + .values( + validAssignments.map((assignment) => ({ + user_id, + tenant_id: assignment.tenant_id, + role_id: assignment.role_id, + created_by: currentUser.id, + })) + ); + } + + if (removedTenantIds.length) { + await server.db + .update(authProfiles) + .set({ user_id: null }) + .where( + and( + eq(authProfiles.user_id, user_id), + inArray(authProfiles.tenant_id, removedTenantIds) + ) + ); + } + + const existingProfileTenantIds = new Set(existingUserProfiles.map((profile) => profile.tenant_id)); + + for (const tenantId of tenantIds) { + if (existingProfileTenantIds.has(tenantId)) continue; + + const requestedProfileId = requestedProfileAssignments.get(tenantId); + const freeProfile = requestedProfileId + ? unassignedProfiles.find((profile) => profile.id === requestedProfileId && profile.tenant_id === tenantId) + : null; + + if (freeProfile) { + await server.db + .update(authProfiles) + .set({ user_id }) + .where(eq(authProfiles.id, freeProfile.id)); + continue; + } + + await server.db + .insert(authProfiles) + .values({ + user_id, + tenant_id: tenantId, + first_name: profileDefaults.first_name, + last_name: profileDefaults.last_name, + email: targetUser.email, + active: true, + }); + } + + return { + success: true, + tenant_ids: tenantIds, + role_assignments: validAssignments, + }; + } catch (err) { + console.error("ERROR /admin/users/:user_id/access:", err); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }); // ------------------------------------------------------------- // POST /admin/add-user-to-tenant // ------------------------------------------------------------- server.post("/admin/add-user-to-tenant", async (req, reply) => { try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + const body = req.body as { user_id: string; tenant_id: number; @@ -44,11 +714,10 @@ export default async function adminRoutes(server: FastifyInstance) { await server.db .insert(authTenantUsers) - // @ts-ignore .values({ user_id: body.user_id, - tenantId: body.tenant_id, - role: body.role ?? "member", + tenant_id: body.tenant_id, + created_by: currentUser.id, }); return { success: true, mode }; @@ -65,6 +734,9 @@ export default async function adminRoutes(server: FastifyInstance) { // ------------------------------------------------------------- server.get("/admin/user-tenants/:user_id", async (req, reply) => { try { + const currentUser = await requireAdmin(req, reply); + if (!currentUser) return; + const { user_id } = req.params as { user_id: string }; if (!user_id) { diff --git a/backend/src/routes/auth/me.ts b/backend/src/routes/auth/me.ts index d1700fa..34f0ead 100644 --- a/backend/src/routes/auth/me.ts +++ b/backend/src/routes/auth/me.ts @@ -31,6 +31,7 @@ export default async function meRoutes(server: FastifyInstance) { email: authUsers.email, created_at: authUsers.created_at, must_change_password: authUsers.must_change_password, + is_admin: authUsers.is_admin, }) .from(authUsers) .where(eq(authUsers.id, userId)) diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index 4db04ae..64a8772 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -15,6 +15,7 @@ const showMembersNav = computed(() => { const showMemberRelationsNav = computed(() => { return tenantExtraModules.value.includes("verein") && has("members") }) +const isAdmin = computed(() => Boolean(auth.user?.is_admin)) const tenantFeatures = computed(() => auth.activeTenantData?.features || {}) const featureEnabled = (key) => tenantFeatures.value?.[key] !== false @@ -248,6 +249,11 @@ const links = computed(() => { to: "/settings/tenant", icon: "i-heroicons-building-office", } : null, + isAdmin.value ? { + label: "Administration", + to: "/settings/admin", + icon: "i-heroicons-shield-check", + } : null, featureEnabled("export") ? { label: "Export", to: "/export", diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts index 05610e8..816cacc 100644 --- a/frontend/stores/auth.ts +++ b/frontend/stores/auth.ts @@ -4,7 +4,15 @@ import {Preferences} from "@capacitor/preferences"; export const useAuthStore = defineStore("auth", { state: () => ({ - user: null as null | { user_id: string; email: string; tenant_id?: string; role?: string }, + user: null as null | { + id?: string; + user_id?: string; + email: string; + tenant_id?: string; + role?: string; + must_change_password?: boolean; + is_admin?: boolean; + }, profile: null as null | any, tenants: [] as { tenant_id: string; role: string; tenants: { id: string; name: string } }[], permissions: [] as string[],