feat: Implement comprehensive member management with user accounts, roles, and password handling for admin and mobile applications.
This commit is contained in:
@@ -2,12 +2,11 @@
|
||||
|
||||
import { auth, getSanitizedHeaders } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { headers } from 'next/headers'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) {
|
||||
const currentPassword = formData.get('currentPassword') as string
|
||||
const newPassword = formData.get('newPassword') as string
|
||||
const confirmPassword = formData.get('confirmPassword') as string
|
||||
|
||||
@@ -25,42 +24,45 @@ export async function changePasswordAndDisableMustChange(prevState: any, formDat
|
||||
return { success: false, error: 'Nicht authentifiziert.' }
|
||||
}
|
||||
|
||||
let redirectUrl: string | null = null
|
||||
const userId = session.user.id
|
||||
const slug = formData.get('slug') as string
|
||||
|
||||
// Hash and save new password directly — user is already authenticated so no old password needed
|
||||
const newHash = await hashPassword(newPassword)
|
||||
|
||||
const credAccount = await prisma.account.findFirst({
|
||||
where: { userId, providerId: 'credential' },
|
||||
})
|
||||
|
||||
if (credAccount) {
|
||||
await prisma.account.update({
|
||||
where: { id: credAccount.id },
|
||||
data: { password: newHash },
|
||||
})
|
||||
} else {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
accountId: userId,
|
||||
providerId: 'credential',
|
||||
userId,
|
||||
password: newHash,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Clear mustChangePassword
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mustChangePassword: false },
|
||||
})
|
||||
|
||||
// Sign out so the user logs in fresh with the new password
|
||||
try {
|
||||
// Update password using better-auth
|
||||
// This will throw if the current password is invalid or other error occurs
|
||||
await auth.api.changePassword({
|
||||
headers: sanitizedHeaders,
|
||||
body: {
|
||||
newPassword,
|
||||
currentPassword,
|
||||
}
|
||||
})
|
||||
|
||||
// Update mustChangePassword flag in database
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { mustChangePassword: false }
|
||||
})
|
||||
|
||||
const slug = formData.get('slug') as string
|
||||
|
||||
// Sign out so the user has to re-login with the new password
|
||||
await auth.api.signOut({ headers: sanitizedHeaders })
|
||||
|
||||
redirectUrl = `/login?message=password_changed&callbackUrl=/${slug}/dashboard`
|
||||
} catch (e: any) {
|
||||
console.error('Password reset exception:', e)
|
||||
// BetterAuth errors often have a message or code
|
||||
const errorMessage = e?.message?.toLowerCase() || ''
|
||||
if (errorMessage.includes('invalid') && errorMessage.includes('password')) {
|
||||
return { success: false, error: 'Das aktuelle Passwort ist nicht korrekt.' }
|
||||
}
|
||||
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
redirect(redirectUrl)
|
||||
}
|
||||
redirect(`/login?message=password_changed&callbackUrl=/${slug}/dashboard`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { changePasswordAndDisableMustChange } from '../actions'
|
||||
|
||||
export function ForcePasswordChange({ slug }: { slug: string }) {
|
||||
@@ -9,25 +9,14 @@ export function ForcePasswordChange({ slug }: { slug: string }) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Passwort ändern</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Passwort festlegen</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Dies ist Ihre erste Anmeldung mit den vom Administrator vergebenen Zugangsdaten.
|
||||
Bitte vergeben Sie ein neues, sicheres Passwort.
|
||||
Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={action} className="space-y-4">
|
||||
<input type="hidden" name="slug" value={slug} />
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Aktuelles (temporäres) Passwort</label>
|
||||
<input
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Neues Passwort</label>
|
||||
@@ -36,7 +25,7 @@ export function ForcePasswordChange({ slug }: { slug: string }) {
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="••••••••"
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
@@ -62,7 +51,7 @@ export function ForcePasswordChange({ slug }: { slug: string }) {
|
||||
disabled={isPending}
|
||||
className="w-full bg-gray-900 text-white py-2.5 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{isPending ? 'Speichern...' : 'Passwort aktualisieren'}
|
||||
{isPending ? 'Speichern...' : 'Passwort festlegen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default async function DashboardLayout({
|
||||
// @ts-ignore - mustChangePassword is added via additionalFields
|
||||
if (session.user.mustChangePassword) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
|
||||
<div className="min-h-screen overflow-y-auto bg-gray-50 flex flex-col items-center justify-center p-4">
|
||||
<ForcePasswordChange slug={slug} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -119,6 +119,7 @@ export default function MitgliedEditPage({
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
|
||||
<select value={form.sparte} onChange={(e) => setForm({ ...form, sparte: e.target.value })} className={inputClass}>
|
||||
<option value="">— Bitte wählen —</option>
|
||||
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function MitgliedNeuPage() {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
betrieb: '',
|
||||
sparte: 'Elektrotechnik',
|
||||
sparte: '',
|
||||
ort: '',
|
||||
telefon: '',
|
||||
email: '',
|
||||
@@ -55,28 +55,27 @@ export default function MitgliedNeuPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb *</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
|
||||
<input
|
||||
required
|
||||
value={form.betrieb}
|
||||
onChange={(e) => setForm({ ...form, betrieb: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
|
||||
<select
|
||||
value={form.sparte}
|
||||
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">— Bitte wählen —</option>
|
||||
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort *</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
||||
<input
|
||||
required
|
||||
value={form.ort}
|
||||
onChange={(e) => setForm({ ...form, ort: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { headers } from 'next/headers'
|
||||
// @ts-ignore
|
||||
import { hashPassword } from 'better-auth/crypto'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { newPassword } = await req.json()
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen haben.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const newHash = await hashPassword(newPassword)
|
||||
|
||||
const credAccount = await prisma.account.findFirst({
|
||||
where: { userId, providerId: 'credential' },
|
||||
})
|
||||
|
||||
if (credAccount) {
|
||||
await prisma.account.update({ where: { id: credAccount.id }, data: { password: newHash } })
|
||||
} else {
|
||||
const { randomUUID } = await import('node:crypto')
|
||||
await prisma.account.create({
|
||||
data: { id: randomUUID(), accountId: userId, providerId: 'credential', userId, password: newHash },
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: userId }, data: { mustChangePassword: false } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export default function PasswortAendernPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen overflow-y-auto bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-lg border p-8">
|
||||
<div className="mb-6">
|
||||
|
||||
Reference in New Issue
Block a user