Files
tessera-ctl/apps/web/src/lib/auth-actions.ts
T
schalli f4ece4890d
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 40s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m19s
fix(web): redirect from server action after password change
client-side router.push races with Set-Cookie processing. redirect() in the
server action sends cookie + redirect in one response — browser applies the
new JWT before navigating, so middleware sees mustChangePassword=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 07:20:14 +02:00

189 lines
5.3 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;
}
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;
console.log('[changePasswordAction] session present:', !!session, 'API_URL:', API_URL);
if (!session) {
console.error('[changePasswordAction] no session cookie found');
return { success: false, error: 'networkError' };
}
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 }),
});
console.log('[changePasswordAction] API response status:', response.status);
if (!response.ok) {
const data = await response.json().catch(() => null);
console.error('[changePasswordAction] API error:', data);
if (data?.message === 'Current password is incorrect') {
return { success: false, error: 'wrongCurrentPassword' };
}
return { success: false, error: 'networkError' };
}
// Forward new session cookie from API (mustChangePassword=false baked in)
const setCookieHeader = response.headers.get('set-cookie');
if (setCookieHeader) {
const sessionMatch = setCookieHeader.match(/session=([^;]+)/);
if (sessionMatch) {
const maxAgeMatch = setCookieHeader.match(/Max-Age=(\d+)/i);
cookieStore.set('session', sessionMatch[1], {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
...(maxAgeMatch ? { maxAge: parseInt(maxAgeMatch[1]) } : {}),
});
}
}
redirect('/');
} catch (err) {
console.error('[changePasswordAction] fetch threw:', err);
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;
}
}