KI-AGENT: Passwort-Reset im Admin-Dashboard ergänzen
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 48s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s
Build and Push Docker Images / build-frontend (push) Successful in 1m24s
Build and Push Docker Images / build-website (push) Successful in 23s

This commit is contained in:
2026-09-04 08:00:46 +02:00
parent 7c58b9021a
commit b5419bb5ea
3 changed files with 204 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ import { pipeline } from "node:stream/promises";
import {
authTenantUsers,
authProfiles,
authRefreshTokens,
customers,
authRoles,
authUserRoles,
@@ -1257,6 +1258,64 @@ export default async function adminRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// POST /admin/users/:user_id/reset-password
// -------------------------------------------------------------
server.post("/admin/users/:user_id/reset-password", 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 { password?: string };
const requestedPassword = body.password?.trim();
if (requestedPassword && requestedPassword.length < 8) {
return reply.code(400).send({ error: "Password must contain at least 8 characters" });
}
const initialPassword = requestedPassword || generateRandomPassword(14);
const passwordHash = await hashPassword(initialPassword);
const updatedUser = await server.db.transaction(async (tx) => {
const [user] = await tx
.update(authUsers)
.set({
passwordHash,
must_change_password: true,
updatedAt: new Date(),
})
.where(eq(authUsers.id, user_id))
.returning({
id: authUsers.id,
email: authUsers.email,
must_change_password: authUsers.must_change_password,
});
if (user) {
await tx
.update(authRefreshTokens)
.set({ revokedAt: new Date() })
.where(eq(authRefreshTokens.userId, user_id));
}
return user;
});
if (!updatedUser) {
return reply.code(404).send({ error: "User not found" });
}
return {
user: updatedUser,
initialPassword,
};
} catch (err) {
console.error("ERROR /admin/users/:user_id/reset-password:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// PUT /admin/tenants/:tenant_id
// -------------------------------------------------------------

View File

@@ -190,6 +190,13 @@ export const useAdmin = () => {
})
}
const resetUserPassword = async (id: string, password?: string) => {
return await $api(`/api/admin/users/${id}/reset-password`, {
method: "POST",
body: { password: password?.trim() || undefined },
}) as { initialPassword: string }
}
const updateUserAccess = async (id: string, body: Record<string, any>) => {
return await $api(`/api/admin/users/${id}/access`, {
method: "PUT",
@@ -276,6 +283,7 @@ export const useAdmin = () => {
createUser,
createUserForProfile,
updateUser,
resetUserPassword,
updateUserAccess,
createTenant,
invitePortalUser,

View File

@@ -10,6 +10,10 @@ const admin = useAdmin()
const userId = route.params.id as string
const loading = ref(true)
const saving = ref(false)
const resettingPassword = ref(false)
const resetPasswordModalOpen = ref(false)
const resetPasswordInput = ref("")
const resetPasswordResult = ref("")
const userForm = ref<AdminUser | null>(null)
const roles = ref<AdminRole[]>([])
@@ -168,6 +172,64 @@ const saveUser = async () => {
}
}
const resetPassword = async () => {
if (!userForm.value || resettingPassword.value) return
const password = resetPasswordInput.value.trim()
if (password && password.length < 8) {
toast.add({ title: "Das Passwort muss mindestens 8 Zeichen lang sein", color: "red" })
return
}
resettingPassword.value = true
try {
const response = await admin.resetUserPassword(userForm.value.id, password)
resetPasswordResult.value = response.initialPassword
resetPasswordInput.value = ""
userForm.value.must_change_password = true
toast.add({
title: "Passwort zurückgesetzt",
description: "Das neue Initialpasswort wird einmalig angezeigt.",
color: "green",
})
} catch (err: any) {
console.error("[administration/users/reset-password]", err)
toast.add({
title: "Passwort konnte nicht zurückgesetzt werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red",
})
} finally {
resettingPassword.value = false
}
}
const copyResetPassword = async () => {
if (!resetPasswordResult.value) return
try {
await navigator.clipboard.writeText(resetPasswordResult.value)
toast.add({ title: "Passwort kopiert", color: "green" })
} catch (err) {
console.error("[administration/users/copy-password]", err)
toast.add({ title: "Passwort konnte nicht kopiert werden", color: "red" })
}
}
const openResetPasswordModal = () => {
resetPasswordInput.value = ""
resetPasswordResult.value = ""
resetPasswordModalOpen.value = true
}
watch(resetPasswordModalOpen, (open) => {
if (open) return
resetPasswordInput.value = ""
resetPasswordResult.value = ""
})
onMounted(async () => {
if (!auth.user?.is_admin) {
await router.push("/")
@@ -186,6 +248,14 @@ onMounted(async () => {
</UButton>
</template>
<template #right>
<UButton
color="warning"
variant="soft"
icon="i-heroicons-key"
@click="openResetPasswordModal"
>
Passwort zurücksetzen
</UButton>
<UButton color="primary" :loading="saving" @click="saveUser">
Speichern
</UButton>
@@ -327,4 +397,71 @@ onMounted(async () => {
<USkeleton v-if="loading" class="h-80" />
</UDashboardPanelContent>
<UModal v-model:open="resetPasswordModalOpen">
<template #content>
<UCard>
<template #header>
<div class="text-lg font-semibold">Passwort zurücksetzen</div>
</template>
<div class="space-y-4">
<p class="text-sm text-gray-600">
Setzt das Passwort für <strong>{{ userForm?.email }}</strong> sofort zurück.
Beim nächsten Login muss der Benutzer ein eigenes Passwort vergeben.
</p>
<UFormField label="Neues Initialpasswort">
<UInput
v-model="resetPasswordInput"
type="text"
autocomplete="off"
placeholder="Leer lassen für automatisches Passwort"
class="w-full"
/>
</UFormField>
<UAlert
v-if="resetPasswordResult"
title="Neues Initialpasswort"
description="Dieses Passwort wird nur hier angezeigt. Bitte sicher an den Benutzer übermitteln."
color="warning"
variant="soft"
>
<template #actions>
<div class="flex items-center gap-2">
<code class="select-all rounded bg-gray-100 px-3 py-2 text-gray-900">
{{ resetPasswordResult }}
</code>
<UButton
icon="i-heroicons-clipboard-document"
variant="soft"
@click="copyResetPassword"
>
Kopieren
</UButton>
</div>
</template>
</UAlert>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<UButton variant="soft" color="gray" @click="resetPasswordModalOpen = false">
Schließen
</UButton>
<UButton
v-if="!resetPasswordResult"
color="warning"
icon="i-heroicons-key"
:loading="resettingPassword"
@click="resetPassword"
>
Passwort zurücksetzen
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>