'use server'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; const API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; export interface AuthUser { id: string; username: string; displayName: string | null; role: 'SUPER_ADMIN' | 'ADMIN' | 'USER'; tenantId: string; mustChangePassword: boolean; isLocalUser?: boolean; hasAvatar?: boolean; accentColor?: string | null; } export interface LoginResult { success: boolean; error?: string; user?: AuthUser; } /** * Login action: POST credentials to API, forward session cookie. * In development, the API runs on a different port (3001) so we * must manually forward the Set-Cookie header from the API response. */ export async function login(formData: FormData): Promise { const username = formData.get('username') as string; const password = formData.get('password') as string; const rememberMe = formData.get('rememberMe') === 'on'; if (!username || !password) { return { success: false, error: 'invalidCredentials' }; } try { const response = await fetch(`${API_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); if (!response.ok) { return { success: false, error: 'invalidCredentials' }; } const user: AuthUser = await response.json(); // Forward the session cookie from the API response to the browser const setCookieHeader = response.headers.get('set-cookie'); if (setCookieHeader) { const sessionMatch = setCookieHeader.match(/session=([^;]+)/); if (sessionMatch) { const cookieStore = await cookies(); cookieStore.set('session', sessionMatch[1], { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', ...(rememberMe ? { maxAge: 30 * 24 * 60 * 60 } : {}), path: '/', }); } } return { success: true, user }; } catch { return { success: false, error: 'networkError' }; } } /** * Logout action: POST to API, clear local cookie, redirect to /login. */ export async function logout(): Promise { const cookieStore = await cookies(); const session = cookieStore.get('session')?.value; try { await fetch(`${API_URL}/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(session ? { Cookie: `session=${session}` } : {}), }, credentials: 'include', }); } catch { // Logout should still clear the cookie even if API call fails } cookieStore.delete('session'); redirect('/login'); } export type ChangePasswordResult = | { success: false; error: 'wrongCurrentPassword' | 'networkError' }; export async function changePasswordAction( currentPassword: string, newPassword: string, ): Promise { const cookieStore = await cookies(); const session = cookieStore.get('session')?.value; if (!session) { return { success: false, error: 'networkError' }; } let newSessionToken: string | undefined; let newSessionMaxAge: number | undefined; try { const response = await fetch(`${API_URL}/auth/change-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `session=${session}`, }, body: JSON.stringify({ currentPassword, newPassword }), }); if (!response.ok) { const data = await response.json().catch(() => null); if (data?.message === 'Current password is incorrect') { return { success: false, error: 'wrongCurrentPassword' }; } return { success: false, error: 'networkError' }; } const setCookieHeader = response.headers.get('set-cookie'); if (setCookieHeader) { const sessionMatch = setCookieHeader.match(/session=([^;]+)/); const maxAgeMatch = setCookieHeader.match(/Max-Age=(\d+)/i); if (sessionMatch) { newSessionToken = sessionMatch[1]; newSessionMaxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : undefined; } } } catch (err) { console.error('[changePasswordAction] fetch threw:', err); return { success: false, error: 'networkError' }; } if (newSessionToken) { cookieStore.set('session', newSessionToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', ...(newSessionMaxAge ? { maxAge: newSessionMaxAge } : {}), }); } redirect('/'); } export interface UploadAvatarResult { success: boolean; error?: string; } /** * Upload a profile picture for the currently authenticated user. * Forwards the multipart file to POST /users/me/avatar using the session cookie. * Returns a discriminated result — does NOT redirect. */ export async function uploadAvatarAction( formData: FormData, ): Promise { const cookieStore = await cookies(); const session = cookieStore.get('session')?.value; if (!session) { return { success: false, error: 'notAuthenticated' }; } try { const response = await fetch(`${API_URL}/users/me/avatar`, { method: 'POST', headers: { Cookie: `session=${session}`, }, body: formData, }); if (!response.ok) { const data = await response.json().catch(() => null); return { success: false, error: data?.message ?? 'uploadError' }; } return { success: true }; } catch (err) { console.error('[uploadAvatarAction] fetch threw:', err); return { success: false, error: 'networkError' }; } } export async function deleteAvatarAction(): Promise<{ success: boolean; error?: string }> { const cookieStore = await cookies(); const session = cookieStore.get('session')?.value; if (!session) return { success: false, error: 'notAuthenticated' }; try { const response = await fetch(`${API_URL}/users/me/avatar`, { method: 'DELETE', headers: { Cookie: `session=${session}` }, }); if (!response.ok) return { success: false, error: 'deleteError' }; return { success: true }; } catch { return { success: false, error: 'networkError' }; } } export async function updateAccentColorAction( color: string | null, ): Promise<{ success: boolean; error?: string }> { const cookieStore = await cookies(); const session = cookieStore.get('session')?.value; if (!session) return { success: false, error: 'notAuthenticated' }; try { const response = await fetch(`${API_URL}/users/me/accent-color`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Cookie: `session=${session}` }, body: JSON.stringify({ color }), }); if (!response.ok) return { success: false, error: 'saveError' }; return { success: true }; } catch { return { success: false, error: 'networkError' }; } } export type SessionState = | { status: 'authenticated'; user: AuthUser } | { status: 'unauthenticated' } | { status: 'unavailable' }; /** * Klassifiziert die aktuelle Sitzung fuer den Header-Waechter * (quick-260917-gyd). Die Unterscheidung ist load-bearing: nur eine * nachweislich tote Sitzung (401/403 oder 200 ohne Benutzerobjekt — so * antwortet NestJS, wenn `AuthService.getMe` bei geloeschtem Benutzer * `null` liefert, z. B. nach Neuanlage der Datenbank) darf das Cookie * loeschen und abmelden. Ein API-Ausfall (5xx, Netzwerkfehler, Antwort * ohne gueltiges JSON) darf KEINE Abmelde-Schleife ausloesen und liefert * deshalb `unavailable`, ohne das Cookie anzufassen. * * `cookieStore.delete()` ist nur in Server Actions/Route Handlers * erlaubt — deshalb passiert die Loeschung hier und nicht im Header. */ export async function fetchSessionState(): Promise { const cookieStore = await cookies(); const session = cookieStore.get('session')?.value; if (!session) { return { status: 'unauthenticated' }; } try { const response = await fetch(`${API_URL}/auth/me`, { headers: { Cookie: `session=${session}`, }, cache: 'no-store', }); if (response.status === 401 || response.status === 403) { cookieStore.delete('session'); return { status: 'unauthenticated' }; } if (!response.ok) { return { status: 'unavailable' }; } const body = (await response.text()).trim(); if (body === '' || body === 'null') { cookieStore.delete('session'); return { status: 'unauthenticated' }; } let parsed: unknown; try { parsed = JSON.parse(body); } catch { return { status: 'unavailable' }; } if (parsed && typeof parsed === 'object' && 'id' in parsed) { return { status: 'authenticated', user: parsed as AuthUser }; } return { status: 'unavailable' }; } catch { return { status: 'unavailable' }; } } /** * Fetch the current authenticated user from the API. * Uses the session cookie for authentication. */ export async function fetchCurrentUser(): Promise { const cookieStore = await cookies(); const session = cookieStore.get('session')?.value; if (!session) { return null; } try { const response = await fetch(`${API_URL}/auth/me`, { headers: { Cookie: `session=${session}`, }, credentials: 'include', cache: 'no-store', }); if (!response.ok) { return null; } return await response.json(); } catch { return null; } }