feat: Implement comprehensive member management with user accounts, roles, and password handling for admin and mobile applications.

This commit is contained in:
Timo Knuth 2026-02-27 18:50:17 +01:00
parent 253c3c1c6d
commit 4863d032d9
12 changed files with 148 additions and 115 deletions

View File

@ -2,12 +2,11 @@
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
// @ts-ignore
import { hashPassword } from 'better-auth/crypto'
export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) { export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) {
const currentPassword = formData.get('currentPassword') as string
const newPassword = formData.get('newPassword') as string const newPassword = formData.get('newPassword') as string
const confirmPassword = formData.get('confirmPassword') 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.' } return { success: false, error: 'Nicht authentifiziert.' }
} }
let redirectUrl: string | null = null const userId = session.user.id
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 const slug = formData.get('slug') as string
// Sign out so the user has to re-login with the new password // 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 {
await auth.api.signOut({ headers: sanitizedHeaders }) await auth.api.signOut({ headers: sanitizedHeaders })
} catch {
redirectUrl = `/login?message=password_changed&callbackUrl=/${slug}/dashboard` // ignore
} 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.' }
} }
if (redirectUrl) { redirect(`/login?message=password_changed&callbackUrl=/${slug}/dashboard`)
redirect(redirectUrl)
}
} }

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState } from 'react'
import { changePasswordAndDisableMustChange } from '../actions' import { changePasswordAndDisableMustChange } from '../actions'
export function ForcePasswordChange({ slug }: { slug: string }) { export function ForcePasswordChange({ slug }: { slug: string }) {
@ -9,25 +9,14 @@ export function ForcePasswordChange({ slug }: { slug: string }) {
return ( return (
<div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm"> <div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm">
<div className="mb-6"> <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"> <p className="text-gray-500 text-sm">
Dies ist Ihre erste Anmeldung mit den vom Administrator vergebenen Zugangsdaten. Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account.
Bitte vergeben Sie ein neues, sicheres Passwort.
</p> </p>
</div> </div>
<form action={action} className="space-y-4"> <form action={action} className="space-y-4">
<input type="hidden" name="slug" value={slug} /> <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> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Neues Passwort</label> <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" type="password"
required required
minLength={8} 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" className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/> />
</div> </div>
@ -62,7 +51,7 @@ export function ForcePasswordChange({ slug }: { slug: string }) {
disabled={isPending} 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" 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> </button>
</form> </form>
</div> </div>

View File

@ -94,7 +94,7 @@ export default async function DashboardLayout({
// @ts-ignore - mustChangePassword is added via additionalFields // @ts-ignore - mustChangePassword is added via additionalFields
if (session.user.mustChangePassword) { if (session.user.mustChangePassword) {
return ( 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} /> <ForcePasswordChange slug={slug} />
</div> </div>
) )

View File

@ -119,6 +119,7 @@ export default function MitgliedEditPage({
<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={inputClass}> <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>)} {SPARTEN.map((s) => <option key={s}>{s}</option>)}
</select> </select>
</div> </div>

View File

@ -11,7 +11,7 @@ export default function MitgliedNeuPage() {
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
betrieb: '', betrieb: '',
sparte: 'Elektrotechnik', sparte: '',
ort: '', ort: '',
telefon: '', telefon: '',
email: '', email: '',
@ -55,28 +55,27 @@ export default function MitgliedNeuPage() {
/> />
</div> </div>
<div className="col-span-2"> <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 <input
required
value={form.betrieb} value={form.betrieb}
onChange={(e) => setForm({ ...form, betrieb: e.target.value })} 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" 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>
<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 <select
value={form.sparte} value={form.sparte}
onChange={(e) => setForm({ ...form, sparte: e.target.value })} 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" 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>)} {SPARTEN.map((s) => <option key={s}>{s}</option>)}
</select> </select>
</div> </div>
<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 <input
required
value={form.ort} value={form.ort}
onChange={(e) => setForm({ ...form, ort: e.target.value })} 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" 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"

View File

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

View File

@ -54,7 +54,7 @@ export default function PasswortAendernPage() {
} }
return ( 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="w-full max-w-sm">
<div className="bg-white rounded-lg border p-8"> <div className="bg-white rounded-lg border p-8">
<div className="mb-6"> <div className="mb-6">

View File

@ -48,8 +48,7 @@ export const auth = betterAuth({
], ],
session: { session: {
cookieCache: { cookieCache: {
enabled: true, enabled: false,
maxAge: 60 * 5, // 5 minutes cache
}, },
}, },
}) })

View File

@ -55,11 +55,14 @@ async function createUserDirectly(opts: { email: string; name: string; password:
return { id: userId } return { id: userId }
} }
const nonEmptyString = (min = 2) =>
z.preprocess((val) => (val === '' ? undefined : val), z.string().min(min).optional())
const MemberInput = z.object({ const MemberInput = z.object({
name: z.string().min(2), name: z.string().min(2),
betrieb: z.string().min(2), betrieb: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
sparte: z.string().min(2), sparte: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
ort: z.string().min(2), ort: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
telefon: z.string().optional(), telefon: z.string().optional(),
email: z.string().email(), email: z.string().email(),
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).default('aktiv'), status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).default('aktiv'),
@ -375,7 +378,22 @@ export const membersRouter = router({
* Update member (admin only) * Update member (admin only)
*/ */
update: adminProcedure update: adminProcedure
.input(z.object({ id: z.string(), data: MemberInput.partial().extend({ role: z.enum(['member', 'admin']).optional() }) })) .input(z.object({
id: z.string(),
data: z.object({
name: nonEmptyString(),
betrieb: nonEmptyString(),
sparte: nonEmptyString(),
ort: nonEmptyString(),
telefon: z.string().optional(),
email: z.preprocess((v) => (v === '' ? undefined : v), z.string().email().optional()),
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).optional(),
istAusbildungsbetrieb: z.boolean().optional(),
seit: z.number().int().min(1900).max(2100).optional(),
role: z.enum(['member', 'admin']).optional(),
password: z.preprocess((val) => (val === '' ? undefined : val), z.string().min(8).optional()),
}),
}))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { role, password, ...memberData } = input.data const { role, password, ...memberData } = input.data
@ -524,19 +542,12 @@ export const membersRouter = router({
where: { userId: targetUserId, providerId: 'credential' } where: { userId: targetUserId, providerId: 'credential' }
}) })
if (existingCredAccount) { if (existingCredAccount) {
const authHeaders = await getSanitizedHeaders() // Update password hash directly — auth.api.updateUser requires Better Auth admin role
let nameForUpdate = memberData.name // which our custom UserRole system doesn't set, causing silent failures.
if (!nameForUpdate) { const newHash = await hashPassword(password)
const existingUser = await ctx.prisma.user.findUnique({ where: { id: targetUserId }, select: { name: true } }) await ctx.prisma.account.update({
nameForUpdate = existingUser?.name ?? undefined where: { id: existingCredAccount.id },
} data: { password: newHash },
await auth.api.updateUser({
body: {
userId: targetUserId,
password,
...(nameForUpdate && { name: nameForUpdate }),
},
headers: authHeaders,
}) })
} else { } else {
// No credential account — create one from scratch using Better Auth's own hash format // No credential account — create one from scratch using Better Auth's own hash format

View File

@ -57,7 +57,6 @@ function ChatTabIcon({ color, focused }: { color: string; focused: boolean }) {
function ForcePasswordChangeScreen() { function ForcePasswordChangeScreen() {
const { setSession, signOut } = useAuthStore() const { setSession, signOut } = useAuthStore()
const [current, setCurrent] = useState('')
const [next, setNext] = useState('') const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('') const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -65,32 +64,39 @@ function ForcePasswordChangeScreen() {
async function handleSubmit() { async function handleSubmit() {
setError('') setError('')
if (!current) { setError('Bitte temporäres Passwort eingeben.'); return }
if (next.length < 8) { setError('Das neue Passwort muss mindestens 8 Zeichen haben.'); return } if (next.length < 8) { setError('Das neue Passwort muss mindestens 8 Zeichen haben.'); return }
if (next !== confirm) { setError('Die Passwörter stimmen nicht überein.'); return } if (next !== confirm) { setError('Die Passwörter stimmen nicht überein.'); return }
setLoading(true) setLoading(true)
const result = await authClient.changePassword({ currentPassword: current, newPassword: next }) // Set password directly via tRPC (no old password needed — user is already authenticated)
try {
const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3032'
const sessionResult = await authClient.getSession()
const token = (sessionResult?.data as any)?.session?.token
const res = await fetch(`${apiUrl}/api/auth/force-set-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
body: JSON.stringify({ newPassword: next }),
})
const data = await res.json()
if (!res.ok || data.error) {
setError(data.error ?? 'Passwort konnte nicht geändert werden.')
setLoading(false) setLoading(false)
if (result.error) {
setError(result.error.message ?? 'Passwort konnte nicht geändert werden.')
return return
} }
// Update local session state
// Refresh session — mustChangePassword is now false
const sessionResult = await authClient.getSession()
if (sessionResult?.data?.user) { if (sessionResult?.data?.user) {
const u = sessionResult.data.user as any const u = sessionResult.data.user as any
await setSession({ await setSession({ user: { id: u.id, email: u.email, name: u.name, mustChangePassword: false } })
user: {
id: u.id,
email: u.email,
name: u.name,
mustChangePassword: false,
},
})
} }
} catch (e) {
setError('Verbindungsfehler. Bitte erneut versuchen.')
}
setLoading(false)
} }
return ( return (
@ -100,23 +106,11 @@ function ForcePasswordChangeScreen() {
<View style={fpc.iconWrap}> <View style={fpc.iconWrap}>
<Ionicons name="lock-closed-outline" size={32} color="#003B7E" /> <Ionicons name="lock-closed-outline" size={32} color="#003B7E" />
</View> </View>
<Text style={fpc.title}>Passwort ändern</Text> <Text style={fpc.title}>Passwort festlegen</Text>
<Text style={fpc.subtitle}> <Text style={fpc.subtitle}>
Ihr Administrator hat ein temporäres Passwort vergeben. Bitte legen Sie jetzt Ihr persönliches Passwort fest. Bitte legen Sie jetzt Ihr persönliches Passwort fest.
</Text> </Text>
<View style={fpc.field}>
<Text style={fpc.label}>Temporäres Passwort</Text>
<TextInput
style={fpc.input}
value={current}
onChangeText={setCurrent}
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={fpc.field}> <View style={fpc.field}>
<Text style={fpc.label}>Neues Passwort</Text> <Text style={fpc.label}>Neues Passwort</Text>
<TextInput <TextInput
@ -166,7 +160,7 @@ function ForcePasswordChangeScreen() {
const fpc = StyleSheet.create({ const fpc = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' }, safe: { flex: 1, backgroundColor: '#F8FAFC' },
content: { flex: 1, justifyContent: 'center', padding: 24 }, content: { flexGrow: 1, justifyContent: 'center', padding: 24, paddingBottom: 40 },
card: { card: {
backgroundColor: '#FFFFFF', borderRadius: 20, backgroundColor: '#FFFFFF', borderRadius: 20,
borderWidth: 1, borderColor: '#E2E8F0', borderWidth: 1, borderColor: '#E2E8F0',

View File

@ -482,7 +482,7 @@ export default function ProfilScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#F8FAFC' }, safeArea: { flex: 1, backgroundColor: '#F8FAFC' },
content: { paddingHorizontal: 18, paddingBottom: 30, gap: 14 }, content: { paddingHorizontal: 18, paddingBottom: 120, gap: 14 },
// Hero // Hero
hero: { hero: {