Add password reset flow

This commit is contained in:
Remi
2025-09-17 16:21:11 +02:00
parent ab6bf8c3d5
commit 1741405c3f
6 changed files with 421 additions and 24 deletions

View File

@@ -0,0 +1,240 @@
<template>
<div class="min-h-screen bg-[#0b1020] text-white">
<div class="mx-auto max-w-5xl px-6 py-12 lg:py-16 space-y-8">
<NuxtLink to="/login" class="inline-flex items-center gap-2 text-sm text-white/70 hover:text-cyan-300">
<v-icon icon="mdi-arrow-left" size="18" /> Zurück zum Login
</NuxtLink>
<div class="grid gap-10 lg:grid-cols-[1.1fr_1fr] items-start">
<div class="space-y-6">
<div class="space-y-4">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300/80">Passworthilfe</p>
<h1 class="text-3xl md:text-4xl font-semibold">
{{ isResetMode ? 'Neues Passwort festlegen' : 'Passwort zurücksetzen' }}
</h1>
<p class="text-white/70">
{{
isResetMode
? 'Lege jetzt ein neues Passwort für deinen Account fest.'
: 'Trage deine registrierte E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen.'
}}
</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5 space-y-4">
<h2 class="text-lg font-semibold">So funktioniert es</h2>
<ol v-if="!isResetMode" class="space-y-3 text-white/70 text-sm">
<li class="flex gap-3"><span class="chip">1</span><span>E-Mail-Adresse eingeben und Link anfordern.</span></li>
<li class="flex gap-3"><span class="chip">2</span><span>E-Mail öffnen und auf den Link klicken.</span></li>
<li class="flex gap-3"><span class="chip">3</span><span>Neues Passwort vergeben und einloggen.</span></li>
</ol>
<ol v-else class="space-y-3 text-white/70 text-sm">
<li class="flex gap-3"><span class="chip">1</span><span>Neues, sicheres Passwort auswählen.</span></li>
<li class="flex gap-3"><span class="chip">2</span><span>Zur Sicherheit erneut bestätigen.</span></li>
<li class="flex gap-3"><span class="chip">3</span><span>Speichern und mit dem neuen Passwort anmelden.</span></li>
</ol>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-6 space-y-6">
<form v-if="!isResetMode" class="space-y-4" @submit.prevent="submitRequest">
<div>
<label class="text-xs uppercase tracking-[0.3em] text-white/40">E-Mail</label>
<input
v-model.trim="requestForm.email"
type="email"
required
autocomplete="email"
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
/>
</div>
<button type="submit" class="btn btn-primary w-full" :disabled="requestLoading">
<span v-if="requestLoading" class="flex items-center justify-center gap-2">
<v-progress-circular indeterminate size="16" width="2" color="white" />
Link wird gesendet
</span>
<span v-else>Link anfordern</span>
</button>
<p v-if="requestError" class="text-sm text-red-300">{{ requestError }}</p>
<div
v-else-if="requestSuccess"
class="rounded-xl border border-emerald-400/30 bg-emerald-400/10 p-4 text-sm text-emerald-100"
>
Wenn ein Konto zu dieser Adresse existiert, findest du gleich eine E-Mail mit weiteren Schritten in deinem Postfach.
</div>
</form>
<div v-else class="space-y-4">
<div v-if="resetCompleted" class="space-y-4">
<div class="rounded-xl border border-emerald-400/30 bg-emerald-400/10 p-4 text-sm text-emerald-100">
Dein Passwort wurde erfolgreich geändert. Du kannst dich jetzt mit dem neuen Passwort anmelden.
</div>
<NuxtLink to="/login" class="btn btn-primary w-full">Zum Login</NuxtLink>
</div>
<form v-else class="space-y-4" @submit.prevent="submitReset">
<div class="space-y-3">
<div>
<label class="text-xs uppercase tracking-[0.3em] text-white/40">Neues Passwort</label>
<input
v-model="resetForm.password"
type="password"
required
minlength="8"
autocomplete="new-password"
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
/>
<p class="mt-1 text-xs text-white/50">Mindestens 8 Zeichen, gerne inkl. Zahl &amp; Sonderzeichen.</p>
</div>
<div>
<label class="text-xs uppercase tracking-[0.3em] text-white/40">Passwort bestätigen</label>
<input
v-model="resetForm.confirm"
type="password"
required
autocomplete="new-password"
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
/>
</div>
</div>
<p v-if="passwordMismatch" class="text-xs text-red-300">Die Passwörter stimmen nicht überein.</p>
<button type="submit" class="btn btn-primary w-full" :disabled="resetLoading || !canSubmitReset">
<span v-if="resetLoading" class="flex items-center justify-center gap-2">
<v-progress-circular indeterminate size="16" width="2" color="white" />
Speichere neues Passwort
</span>
<span v-else>Passwort speichern</span>
</button>
<p v-if="resetError" class="text-sm text-red-300">{{ resetError }}</p>
<p class="text-xs text-white/60">
Link funktioniert nicht?
<NuxtLink to="/forgot-password" class="text-cyan-300 underline">Neuen Link anfordern</NuxtLink>
</p>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useHead } from '#imports'
import { useApi } from '~/composables/useApi'
const api = useApi()
const route = useRoute()
const requestForm = reactive({
email: '',
})
const requestLoading = ref(false)
const requestError = ref('')
const requestSuccess = ref(false)
const resetForm = reactive({
password: '',
confirm: '',
})
const resetLoading = ref(false)
const resetError = ref('')
const resetCompleted = ref(false)
const token = computed(() => {
const value = route.query.token
if (Array.isArray(value)) {
return value[0] || ''
}
return typeof value === 'string' ? value : ''
})
const isResetMode = computed(() => Boolean(token.value))
const canSubmitReset = computed(
() =>
Boolean(
token.value &&
resetForm.password &&
resetForm.password.length >= 8 &&
resetForm.password === resetForm.confirm
)
)
const passwordMismatch = computed(
() => Boolean(resetForm.confirm && resetForm.password && resetForm.password !== resetForm.confirm)
)
watch(
() => token.value,
() => {
resetForm.password = ''
resetForm.confirm = ''
resetError.value = ''
resetCompleted.value = false
}
)
async function submitRequest() {
if (requestLoading.value) return
requestLoading.value = true
requestError.value = ''
requestSuccess.value = false
try {
await api.post(
'/api/service/auth/forgot-password',
{ email: requestForm.email },
{ auth: false }
)
requestSuccess.value = true
requestForm.email = ''
} catch (err: any) {
const message = err?.data?.statusMessage || err?.message || 'Senden fehlgeschlagen'
requestError.value = message
} finally {
requestLoading.value = false
}
}
async function submitReset() {
if (resetLoading.value || !token.value) return
resetLoading.value = true
resetError.value = ''
try {
await api.post(
'/api/service/auth/reset-password',
{ token: token.value, password: resetForm.password },
{ auth: false }
)
resetCompleted.value = true
resetForm.password = ''
resetForm.confirm = ''
} catch (err: any) {
const message = err?.data?.statusMessage || err?.message || 'Zurücksetzen fehlgeschlagen'
resetError.value = message
} finally {
resetLoading.value = false
}
}
useHead({
title: 'OpenSquawk Passwort zurücksetzen',
meta: [
{ name: 'robots', content: 'noindex,nofollow' },
],
})
</script>
<style scoped>
.chip {
@apply inline-flex h-6 w-6 items-center justify-center rounded-full bg-cyan-500/20 text-xs font-medium text-cyan-200;
}
</style>

View File

@@ -68,6 +68,11 @@
class="mt-1 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 outline-none focus:border-cyan-400"
/>
</div>
<div class="text-right">
<NuxtLink to="/forgot-password" class="text-xs text-cyan-300 underline hover:text-cyan-200">
Passwort vergessen?
</NuxtLink>
</div>
</div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginLoading">

View File

@@ -0,0 +1,55 @@
import { createError, getRequestURL, readBody } from 'h3'
import { randomBytes, createHash } from 'node:crypto'
import { User } from '../../../models/User'
import { PasswordResetToken } from '../../../models/PasswordResetToken'
import { sendMail } from '../../../utils/notifications'
interface ForgotPasswordBody {
email?: string
}
const RESET_TOKEN_TTL_MINUTES = 60
export default defineEventHandler(async (event) => {
const body = await readBody<ForgotPasswordBody>(event)
const email = body.email?.trim().toLowerCase()
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'Bitte eine E-Mail-Adresse angeben' })
}
const user = await User.findOne({ email })
if (user) {
const token = randomBytes(32).toString('hex')
const tokenHash = createHash('sha256').update(token).digest('hex')
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000)
await PasswordResetToken.deleteMany({ user: user._id })
await PasswordResetToken.create({ user: user._id, tokenHash, expiresAt })
const url = getRequestURL(event)
const resetLink = `${url.origin}/forgot-password?token=${encodeURIComponent(token)}`
const salutation = user.name ? ` ${user.name}` : ''
const lines = [
`Hallo${salutation},`,
'',
'du hast den Link zum Zurücksetzen deines OpenSquawk-Passworts angefordert.',
`Klicke innerhalb von ${RESET_TOKEN_TTL_MINUTES} Minuten auf den folgenden Link, um ein neues Passwort festzulegen:`,
resetLink,
'',
'Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.',
'',
'Blue skies,',
'dein OpenSquawk-Team',
]
await sendMail({
to: user.email,
subject: 'OpenSquawk Passwort zurücksetzen',
text: lines.join('\n'),
})
}
return { success: true }
})

View File

@@ -0,0 +1,47 @@
import { createError, readBody } from 'h3'
import { createHash } from 'node:crypto'
import { PasswordResetToken } from '../../../models/PasswordResetToken'
import { User } from '../../../models/User'
import { hashPassword } from '../../../utils/auth'
interface ResetPasswordBody {
token?: string
password?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody<ResetPasswordBody>(event)
const token = body.token?.trim()
const password = body.password?.trim()
if (!token || !password) {
throw createError({ statusCode: 400, statusMessage: 'Token und neues Passwort erforderlich' })
}
if (password.length < 8) {
throw createError({ statusCode: 400, statusMessage: 'Das Passwort muss mindestens 8 Zeichen enthalten' })
}
const tokenHash = createHash('sha256').update(token).digest('hex')
const resetToken = await PasswordResetToken.findOne({ tokenHash })
if (!resetToken || (resetToken.expiresAt && resetToken.expiresAt < new Date()) || resetToken.usedAt) {
throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
}
const user = await User.findById(resetToken.user)
if (!user) {
await resetToken.deleteOne().catch(() => undefined)
throw createError({ statusCode: 400, statusMessage: 'Dieser Link ist ungültig oder abgelaufen' })
}
user.passwordHash = await hashPassword(password)
user.tokenVersion += 1
await user.save()
resetToken.usedAt = new Date()
await resetToken.save()
await PasswordResetToken.deleteMany({ user: user._id, _id: { $ne: resetToken._id } })
return { success: true }
})

View File

@@ -0,0 +1,21 @@
import mongoose from 'mongoose'
export interface PasswordResetTokenDocument extends mongoose.Document {
user: mongoose.Types.ObjectId
tokenHash: string
expiresAt: Date
createdAt: Date
usedAt?: Date
}
const passwordResetTokenSchema = new mongoose.Schema<PasswordResetTokenDocument>({
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
tokenHash: { type: String, required: true, unique: true },
expiresAt: { type: Date, required: true, index: { expires: 0 } },
createdAt: { type: Date, default: () => new Date() },
usedAt: { type: Date },
})
export const PasswordResetToken =
(mongoose.models.PasswordResetToken as mongoose.Model<PasswordResetTokenDocument> | undefined) ||
mongoose.model<PasswordResetTokenDocument>('PasswordResetToken', passwordResetTokenSchema)

View File

@@ -2,15 +2,27 @@ const ADMIN_EMAIL_FALLBACK = 'opensquawk@faktorxmensch.com'
const RESEND_ENDPOINT = 'https://api.resend.com/emails'
async function sendViaResend(subject: string, text: string) {
interface MailOptions {
to: string
subject: string
text: string
from?: string
}
interface MailPayload extends MailOptions {
from: string
}
function resolveFrom(from?: string) {
return from || process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
}
async function sendViaResend(payload: MailPayload) {
const apiKey = process.env.NOTIFY_RESEND_API_KEY
if (!apiKey) {
return false
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
try {
const response = await fetch(RESEND_ENDPOINT, {
method: 'POST',
@@ -18,28 +30,23 @@ async function sendViaResend(subject: string, text: string) {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from,
to,
subject,
text,
}),
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
console.error('Failed to send notification via Resend API', response.status, errorText)
console.error('Failed to send email via Resend API', response.status, errorText)
return false
}
return true
} catch (error) {
console.error('Error while sending notification via Resend API', error)
console.error('Error while sending email via Resend API', error)
return false
}
}
async function sendViaSmtp(subject: string, text: string) {
async function sendViaSmtp(payload: MailPayload) {
const host = process.env.NOTIFY_SMTP_HOST
if (!host) {
return false
@@ -64,13 +71,10 @@ async function sendViaSmtp(subject: string, text: string) {
return false
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk <no-reply@opensquawk.dev>'
try {
const transporter = nodemailer.createTransport({
host,
port: Number.isNaN(port) ? secure ? 465 : 587 : port,
port: Number.isNaN(port) ? (secure ? 465 : 587) : port,
secure,
auth: {
user,
@@ -78,26 +82,51 @@ async function sendViaSmtp(subject: string, text: string) {
},
})
await transporter.sendMail({ from, to, subject, text })
await transporter.sendMail({
from: payload.from,
to: payload.to,
subject: payload.subject,
text: payload.text,
})
return true
} catch (error) {
console.error('Failed to send notification via SMTP', error)
console.error('Failed to send email via SMTP', error)
return false
}
}
export async function sendAdminNotification(subject: string, text: string) {
const sentViaResend = await sendViaResend(subject, text)
async function sendMailInternal(options: MailOptions) {
const payload: MailPayload = {
...options,
from: resolveFrom(options.from),
}
const sentViaResend = await sendViaResend(payload)
if (sentViaResend) {
return true
}
const sentViaSmtp = await sendViaSmtp(subject, text)
const sentViaSmtp = await sendViaSmtp(payload)
if (sentViaSmtp) {
return true
}
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
return false
}
export async function sendMail(options: MailOptions) {
const success = await sendMailInternal(options)
if (!success) {
console.info(`[mail:fallback] ${options.subject}\nEmpfänger: ${options.to}\n${options.text}`)
}
return success
}
export async function sendAdminNotification(subject: string, text: string) {
const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
const success = await sendMailInternal({ to, subject, text })
if (!success) {
console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
}
return success
}