diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 45177fc..cf8ae10 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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 // ------------------------------------------------------------- diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index 52f5c3f..80d0e5f 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -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) => { return await $api(`/api/admin/users/${id}/access`, { method: "PUT", @@ -276,6 +283,7 @@ export const useAdmin = () => { createUser, createUserForProfile, updateUser, + resetUserPassword, updateUserAccess, createTenant, invitePortalUser, diff --git a/frontend/pages/administration/users/[id].vue b/frontend/pages/administration/users/[id].vue index 0000f6e..e5b3875 100644 --- a/frontend/pages/administration/users/[id].vue +++ b/frontend/pages/administration/users/[id].vue @@ -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(null) const roles = ref([]) @@ -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 () => {