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

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

View File

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

View File

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

View File

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