diff --git a/innungsapp/apps/admin/app/[slug]/actions.ts b/innungsapp/apps/admin/app/[slug]/actions.ts index 57abc74..4dee535 100644 --- a/innungsapp/apps/admin/app/[slug]/actions.ts +++ b/innungsapp/apps/admin/app/[slug]/actions.ts @@ -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`) } diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx index 3a86620..78b90a0 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx @@ -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 (
-

Passwort ändern

+

Passwort festlegen

- 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.

-
- - -
@@ -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" />
@@ -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'}
diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/layout.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/layout.tsx index 6dce40a..e1f813e 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/layout.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/layout.tsx @@ -94,7 +94,7 @@ export default async function DashboardLayout({ // @ts-ignore - mustChangePassword is added via additionalFields if (session.user.mustChangePassword) { return ( -
+
) diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/[id]/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/[id]/page.tsx index c2fbef7..b76e9b4 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/[id]/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/[id]/page.tsx @@ -119,6 +119,7 @@ export default function MitgliedEditPage({
diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/neu/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/neu/page.tsx index db99938..1c67ee2 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/neu/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/neu/page.tsx @@ -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() { />
- + 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" />
- +
- + 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" diff --git a/innungsapp/apps/admin/app/api/auth/force-set-password/route.ts b/innungsapp/apps/admin/app/api/auth/force-set-password/route.ts new file mode 100644 index 0000000..2a9fabc --- /dev/null +++ b/innungsapp/apps/admin/app/api/auth/force-set-password/route.ts @@ -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 }) +} diff --git a/innungsapp/apps/admin/app/passwort-aendern/page.tsx b/innungsapp/apps/admin/app/passwort-aendern/page.tsx index 822d45d..556b176 100644 --- a/innungsapp/apps/admin/app/passwort-aendern/page.tsx +++ b/innungsapp/apps/admin/app/passwort-aendern/page.tsx @@ -54,7 +54,7 @@ export default function PasswortAendernPage() { } return ( -
+
diff --git a/innungsapp/apps/admin/lib/auth.ts b/innungsapp/apps/admin/lib/auth.ts index 6a1f297..0e158bc 100644 --- a/innungsapp/apps/admin/lib/auth.ts +++ b/innungsapp/apps/admin/lib/auth.ts @@ -48,8 +48,7 @@ export const auth = betterAuth({ ], session: { cookieCache: { - enabled: true, - maxAge: 60 * 5, // 5 minutes cache + enabled: false, }, }, }) diff --git a/innungsapp/apps/admin/server/routers/members.ts b/innungsapp/apps/admin/server/routers/members.ts index 2b3a36c..ec1313b 100644 --- a/innungsapp/apps/admin/server/routers/members.ts +++ b/innungsapp/apps/admin/server/routers/members.ts @@ -55,11 +55,14 @@ async function createUserDirectly(opts: { email: string; name: string; password: return { id: userId } } +const nonEmptyString = (min = 2) => + z.preprocess((val) => (val === '' ? undefined : val), z.string().min(min).optional()) + const MemberInput = z.object({ name: z.string().min(2), - betrieb: z.string().min(2), - sparte: z.string().min(2), - ort: z.string().min(2), + betrieb: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()), + sparte: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()), + ort: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()), telefon: z.string().optional(), email: z.string().email(), status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).default('aktiv'), @@ -375,7 +378,22 @@ export const membersRouter = router({ * Update member (admin only) */ 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 }) => { const { role, password, ...memberData } = input.data @@ -524,19 +542,12 @@ export const membersRouter = router({ where: { userId: targetUserId, providerId: 'credential' } }) if (existingCredAccount) { - const authHeaders = await getSanitizedHeaders() - let nameForUpdate = memberData.name - if (!nameForUpdate) { - const existingUser = await ctx.prisma.user.findUnique({ where: { id: targetUserId }, select: { name: true } }) - nameForUpdate = existingUser?.name ?? undefined - } - await auth.api.updateUser({ - body: { - userId: targetUserId, - password, - ...(nameForUpdate && { name: nameForUpdate }), - }, - headers: authHeaders, + // Update password hash directly — auth.api.updateUser requires Better Auth admin role + // which our custom UserRole system doesn't set, causing silent failures. + const newHash = await hashPassword(password) + await ctx.prisma.account.update({ + where: { id: existingCredAccount.id }, + data: { password: newHash }, }) } else { // No credential account — create one from scratch using Better Auth's own hash format diff --git a/innungsapp/apps/mobile/app/(app)/_layout.tsx b/innungsapp/apps/mobile/app/(app)/_layout.tsx index b611075..7b01923 100644 --- a/innungsapp/apps/mobile/app/(app)/_layout.tsx +++ b/innungsapp/apps/mobile/app/(app)/_layout.tsx @@ -57,7 +57,6 @@ function ChatTabIcon({ color, focused }: { color: string; focused: boolean }) { function ForcePasswordChangeScreen() { const { setSession, signOut } = useAuthStore() - const [current, setCurrent] = useState('') const [next, setNext] = useState('') const [confirm, setConfirm] = useState('') const [loading, setLoading] = useState(false) @@ -65,32 +64,39 @@ function ForcePasswordChangeScreen() { async function handleSubmit() { 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 !== confirm) { setError('Die Passwörter stimmen nicht überein.'); return } setLoading(true) - const result = await authClient.changePassword({ currentPassword: current, newPassword: next }) - setLoading(false) - - if (result.error) { - setError(result.error.message ?? 'Passwort konnte nicht geändert werden.') - return - } - - // Refresh session — mustChangePassword is now false - const sessionResult = await authClient.getSession() - if (sessionResult?.data?.user) { - const u = sessionResult.data.user as any - await setSession({ - user: { - id: u.id, - email: u.email, - name: u.name, - mustChangePassword: false, + // 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) + return + } + // Update local session state + if (sessionResult?.data?.user) { + const u = sessionResult.data.user as any + await setSession({ user: { id: u.id, email: u.email, name: u.name, mustChangePassword: false } }) + } + } catch (e) { + setError('Verbindungsfehler. Bitte erneut versuchen.') } + setLoading(false) } return ( @@ -100,23 +106,11 @@ function ForcePasswordChangeScreen() { - Passwort ändern + Passwort festlegen - 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. - - Temporäres Passwort - - Neues Passwort