Files
tessera-ctl/apps/web/src/lib/auth-actions.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

335 lines
9.4 KiB
TypeScript

'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<LoginResult> {
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<void> {
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<ChangePasswordResult> {
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<UploadAvatarResult> {
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<SessionState> {
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<AuthUser | null> {
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;
}
}