Files
tessera-ctl/apps/web/src/lib/auth-actions.ts
T
schalli be3680d0df feat(user-settings): avatar delete + accent color
- DELETE /users/me/avatar endpoint with file cleanup
- PATCH /users/me/accent-color with hex validation (#rrggbb)
- auth.service.ts: include accentColor in user select
- AccountSettingsForm: delete-avatar button + accent color picker/save/reset
- auth-actions.ts: deleteAvatarAction + updateAccentColorAction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 08:57:31 +02:00

269 lines
7.5 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]) : 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' };
}
}
/**
* 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;
}
}