153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
import { BASE_URL } from './config';
|
|
import { currentToken } from './clients';
|
|
import { AUTH_CREDENTIALS } from './session';
|
|
import { passwordPolicyMessage, type PasswordPolicy } from '../utils/passwordPolicy';
|
|
|
|
export class AuthApiError extends Error {
|
|
status: number;
|
|
code?: string;
|
|
violations?: string[];
|
|
|
|
constructor(message: string, status: number, code?: string, violations?: string[]) {
|
|
super(message);
|
|
this.name = 'AuthApiError';
|
|
this.status = status;
|
|
this.code = code;
|
|
this.violations = violations;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /usr/password-policy — public endpoint to fetch the app-user password rules.
|
|
*/
|
|
export async function fetchPasswordPolicy(): Promise<PasswordPolicy> {
|
|
const res = await fetch(`${BASE_URL}/usr/password-policy`);
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new AuthApiError(body.error || `Failed to load password policy: ${res.status}`, res.status);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* POST /usr/forgot-password — request a one-time 6-digit reset code to email.
|
|
* The backend ALWAYS responds 200 with a neutral message to prevent account enumeration.
|
|
*/
|
|
export async function forgotPassword(email: string): Promise<{ message: string }> {
|
|
const res = await fetch(`${BASE_URL}/usr/forgot-password`, {
|
|
method: 'POST',
|
|
credentials: AUTH_CREDENTIALS,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: email.trim() }),
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new AuthApiError(body.error || `Failed to request password reset: ${res.status}`, res.status);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* POST /usr/verify-reset-code — exchange the emailed OTP code for a short-lived reset token (JWT).
|
|
*/
|
|
export async function verifyResetCode(email: string, code: string): Promise<{ reset_token: string }> {
|
|
const res = await fetch(`${BASE_URL}/usr/verify-reset-code`, {
|
|
method: 'POST',
|
|
credentials: AUTH_CREDENTIALS,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: email.trim(), code: code.trim() }),
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok || !body?.reset_token) {
|
|
throw new AuthApiError(body.error || 'The code is invalid or has expired.', res.status);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* POST /usr/reset-password — set the new password using the reset token from verify-reset-code.
|
|
*/
|
|
export async function resetPassword(resetToken: string, newPassword: string): Promise<{ message: string }> {
|
|
const res = await fetch(`${BASE_URL}/usr/reset-password`, {
|
|
method: 'POST',
|
|
credentials: AUTH_CREDENTIALS,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ reset_token: resetToken, new_password: newPassword }),
|
|
});
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const msg = passwordPolicyMessage(body) || body.message || `Failed to reset password: ${res.status}`;
|
|
throw new AuthApiError(msg, res.status, body?.code, body?.violations);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* POST /usr/me/password — authenticated self-service password change.
|
|
* Wrong current password returns 403 (inline error, not 401).
|
|
* On 200 success, the backend bumps password_changed_at and invalidates the session JWT.
|
|
*/
|
|
export async function changePassword(
|
|
currentPassword: string,
|
|
newPassword: string,
|
|
token?: string | null
|
|
): Promise<{ message: string }> {
|
|
const authToken = token ?? currentToken();
|
|
if (!authToken) {
|
|
throw new AuthApiError('You must be signed in to change your password', 401);
|
|
}
|
|
|
|
const res = await fetch(`${BASE_URL}/usr/me/password`, {
|
|
method: 'POST',
|
|
credentials: AUTH_CREDENTIALS,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authToken}`,
|
|
},
|
|
body: JSON.stringify({
|
|
current_password: currentPassword,
|
|
new_password: newPassword,
|
|
}),
|
|
});
|
|
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const msg = passwordPolicyMessage(body) || body.error || `Failed to change password: ${res.status}`;
|
|
throw new AuthApiError(msg, res.status, body?.code, body?.violations);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* POST /usr/change-expired-password — completes a login that answered 403 password_change_required.
|
|
* It re-proves the credentials the user just typed and sets the new password.
|
|
* A 200 is the SAME body as /usr/login ({ token, user }).
|
|
*/
|
|
export async function changeExpiredPassword(
|
|
email: string,
|
|
currentPassword: string,
|
|
newPassword: string,
|
|
orgId?: string | number
|
|
): Promise<{ token: string; user: any }> {
|
|
const body: Record<string, unknown> = {
|
|
email: email.trim(),
|
|
current_password: currentPassword,
|
|
new_password: newPassword,
|
|
};
|
|
if (orgId) body.org_id = Number(orgId);
|
|
|
|
const res = await fetch(`${BASE_URL}/usr/change-expired-password`, {
|
|
method: 'POST',
|
|
credentials: AUTH_CREDENTIALS,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const msg = passwordPolicyMessage(data) || data.error || data.message || `Failed to set new password: ${res.status}`;
|
|
throw new AuthApiError(msg, res.status, data.code, data.violations);
|
|
}
|
|
return data;
|
|
}
|