354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import { Injectable } from '@angular/core';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { BehaviorSubject, Observable, of } from 'rxjs';
|
|
import { catchError, filter, finalize, map, shareReplay, take, tap } from 'rxjs/operators';
|
|
import { ApiConfigService } from '../../core/services/api-config.service';
|
|
import { ModuleActions } from '../../core/models/permission.model';
|
|
|
|
// ── Public interfaces ─────────────────────────────────────────────────────────
|
|
|
|
/** Matches the C# MemberInfo DTO exactly. */
|
|
export interface MemberInfo {
|
|
id: number;
|
|
nickName: string | null;
|
|
firstName_en: string;
|
|
lastName_en: string;
|
|
firstName_zh: string | null;
|
|
lastName_zh: string | null;
|
|
}
|
|
|
|
/** Matches the C# UserInfo DTO exactly. */
|
|
export interface UserInfo {
|
|
id: string;
|
|
email: string;
|
|
roles: string[];
|
|
languagePreference: string;
|
|
/**
|
|
* Effective permissions, keyed by camelCased module name (server uses a
|
|
* camelCase dictionary-key policy). Absent for legacy/secret-link tokens.
|
|
*/
|
|
permissions?: Record<string, ModuleActions>;
|
|
/**
|
|
* The church member linked to this account, or absent for admin-only
|
|
* accounts and accounts whose member record was deleted. Flows through
|
|
* login, refresh, and /me so the greeting survives a page reload.
|
|
*/
|
|
memberInfo?: MemberInfo;
|
|
}
|
|
|
|
/** Matches the C# LoginResponse DTO exactly. */
|
|
export interface ApiLoginResponse {
|
|
accessToken: string;
|
|
expiresIn: number;
|
|
user: UserInfo;
|
|
}
|
|
|
|
export interface LoginCredentials {
|
|
email: string;
|
|
password: string;
|
|
/** Reserved for future MFA support — ignored by the current API. */
|
|
mfaCode?: string;
|
|
}
|
|
|
|
export enum LoginResultType {
|
|
Success = 'Success',
|
|
/** Kept dormant — the current API has no MFA endpoint. */
|
|
MfaRequired = 'MfaRequired',
|
|
InvalidCredentials = 'InvalidCredentials',
|
|
Error = 'Error'
|
|
}
|
|
|
|
export interface LoginResult {
|
|
result: LoginResultType;
|
|
responseData?: UserInfo;
|
|
message?: string;
|
|
}
|
|
|
|
/** Matches the C# ValidateInvitationResult DTO. */
|
|
export interface ValidateInvitationResult {
|
|
valid: boolean;
|
|
expired: boolean;
|
|
memberName?: string;
|
|
email?: string;
|
|
}
|
|
|
|
export interface TokenVerificationResult {
|
|
isValid: boolean;
|
|
/** Constructed from JWT claims when using secret-link login. */
|
|
user?: UserInfo;
|
|
/** The raw JWT from the URL — use as the access token for this session. */
|
|
accessToken?: string;
|
|
message?: string;
|
|
expiresAt?: Date;
|
|
requiresMfa?: boolean;
|
|
}
|
|
|
|
// ── Service ───────────────────────────────────────────────────────────────────
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class AuthService {
|
|
|
|
/**
|
|
* In-memory only — never written to localStorage.
|
|
* Non-private intentionally: unit tests seed state via these subjects directly.
|
|
* Production code must use getToken(), getCurrentUser(), and setCurrentUser().
|
|
*/
|
|
accessToken$ = new BehaviorSubject<string | null>(null);
|
|
currentUser$ = new BehaviorSubject<UserInfo | null>(null);
|
|
|
|
/** Observable stream of the current user (null = not authenticated). */
|
|
public currentUser = this.currentUser$.asObservable();
|
|
|
|
private readonly sessionReady$ = new BehaviorSubject(false);
|
|
private refreshInFlight$: Observable<boolean> | null = null;
|
|
private redirectUrl = '/dashboard';
|
|
|
|
constructor(
|
|
private http: HttpClient,
|
|
private apiConfig: ApiConfigService
|
|
) {}
|
|
|
|
// ── Auth API calls ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Authenticate with email + password.
|
|
* On success, stores the access token and user in memory and returns
|
|
* LoginResultType.Success. Never throws — errors are mapped to LoginResult.
|
|
*/
|
|
login(credentials: LoginCredentials): Observable<LoginResult> {
|
|
return this.http.post<ApiLoginResponse>(
|
|
`${this.apiConfig.authUrl}/login`,
|
|
{ email: credentials.email, password: credentials.password },
|
|
{ withCredentials: true }
|
|
).pipe(
|
|
tap(response => {
|
|
this.accessToken$.next(response.accessToken);
|
|
this.currentUser$.next(response.user);
|
|
}),
|
|
map(response => ({
|
|
result: LoginResultType.Success,
|
|
responseData: response.user
|
|
} as LoginResult)),
|
|
catchError(error => {
|
|
if (error.status === 401) {
|
|
return of({
|
|
result: LoginResultType.InvalidCredentials,
|
|
message: error.error?.message || 'Invalid email or password'
|
|
} as LoginResult);
|
|
}
|
|
return of({
|
|
result: LoginResultType.Error,
|
|
message: error.error?.message || 'An error occurred during login'
|
|
} as LoginResult);
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Silently exchange the HttpOnly `rolac_rt` cookie for a new access token.
|
|
* Returns true on success, false if the cookie is absent or expired.
|
|
* Never throws.
|
|
*/
|
|
refresh(): Observable<boolean> {
|
|
if (!this.refreshInFlight$) {
|
|
this.refreshInFlight$ = this.http.post<ApiLoginResponse>(
|
|
`${this.apiConfig.authUrl}/refresh`,
|
|
{},
|
|
{ withCredentials: true }
|
|
).pipe(
|
|
tap(response => {
|
|
this.accessToken$.next(response.accessToken);
|
|
this.currentUser$.next(response.user);
|
|
}),
|
|
map(() => true),
|
|
catchError(() => of(false)),
|
|
finalize(() => {
|
|
this.refreshInFlight$ = null;
|
|
}),
|
|
shareReplay(1)
|
|
);
|
|
}
|
|
return this.refreshInFlight$;
|
|
}
|
|
|
|
/**
|
|
* Changes the current user's password. Sends the cookie so the server can
|
|
* keep the current session alive while revoking the user's other sessions.
|
|
* Emits void on success (204); errors propagate so the caller can show the
|
|
* server message.
|
|
*/
|
|
changePassword(currentPassword: string, newPassword: string): Observable<void> {
|
|
return this.http.post<void>(
|
|
`${this.apiConfig.authUrl}/change-password`,
|
|
{ currentPassword, newPassword },
|
|
{ withCredentials: true }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks whether an invitation token is still usable (anonymous). Used by the
|
|
* public "set your password" page to decide what to show before the member types.
|
|
*/
|
|
validateInvitation(token: string): Observable<ValidateInvitationResult> {
|
|
return this.http.get<ValidateInvitationResult>(
|
|
`${this.apiConfig.authUrl}/invitation/validate`,
|
|
{ params: { token } }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Consumes an invitation: sets the password and logs the member in. On success the
|
|
* server returns a normal login payload, so we store the access token + user (and the
|
|
* refresh cookie is set server-side) exactly like login(). Errors propagate to the caller.
|
|
*/
|
|
acceptInvitation(token: string, newPassword: string): Observable<UserInfo> {
|
|
return this.http.post<ApiLoginResponse>(
|
|
`${this.apiConfig.authUrl}/accept-invitation`,
|
|
{ token, newPassword },
|
|
{ withCredentials: true }
|
|
).pipe(
|
|
tap(response => {
|
|
this.accessToken$.next(response.accessToken);
|
|
this.currentUser$.next(response.user);
|
|
}),
|
|
map(response => response.user)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clears in-memory auth state immediately, then fires a fire-and-forget
|
|
* POST to revoke the server-side refresh token cookie.
|
|
*/
|
|
logout(): void {
|
|
this.accessToken$.next(null);
|
|
this.currentUser$.next(null);
|
|
this.http.post(
|
|
`${this.apiConfig.authUrl}/logout`,
|
|
{},
|
|
{ withCredentials: true }
|
|
).pipe(
|
|
catchError(() => of(null))
|
|
).subscribe();
|
|
}
|
|
|
|
/**
|
|
* Called by APP_INITIALIZER on every page load.
|
|
* Attempts to restore the session via the refresh token cookie.
|
|
* Always resolves — never rejects — so it cannot block app bootstrap.
|
|
*/
|
|
initializeFromRefreshToken(): Promise<void> {
|
|
return new Promise(resolve => {
|
|
this.refresh().pipe(
|
|
finalize(() => {
|
|
this.sessionReady$.next(true);
|
|
resolve();
|
|
})
|
|
).subscribe();
|
|
});
|
|
}
|
|
|
|
/** Resolves once startup session restore has finished (success or failure). */
|
|
whenSessionReady(): Observable<boolean> {
|
|
if (this.sessionReady$.value) {
|
|
return of(true);
|
|
}
|
|
return this.sessionReady$.pipe(filter(Boolean), take(1));
|
|
}
|
|
|
|
// ── State accessors ─────────────────────────────────────────────────────
|
|
|
|
getToken(): string | null {
|
|
return this.accessToken$.value;
|
|
}
|
|
|
|
isAuthenticated(): boolean {
|
|
return this.currentUser$.value !== null && this.getToken() !== null;
|
|
}
|
|
|
|
getCurrentUser(): UserInfo | null {
|
|
return this.currentUser$.value;
|
|
}
|
|
|
|
/**
|
|
* Manually set the current user — used by the MFA success callback
|
|
* and the secret-link token flow.
|
|
*/
|
|
setCurrentUser(user: UserInfo): void {
|
|
this.currentUser$.next(user);
|
|
}
|
|
|
|
setRedirectUrl(url: string): void {
|
|
this.redirectUrl = url;
|
|
}
|
|
|
|
getRedirectUrl(): string {
|
|
return this.redirectUrl;
|
|
}
|
|
|
|
// ── Secret-link token helpers (unchanged logic, updated types) ───────────
|
|
|
|
/**
|
|
* Verifies a JWT token received as a URL parameter (secret-link login).
|
|
* Performs local verification only — no API call.
|
|
* Constructs a UserInfo from the JWT claims (id, email, roles,
|
|
* languagePreference).
|
|
*/
|
|
verifySecretLinkToken(token: string): Observable<TokenVerificationResult> {
|
|
try {
|
|
const tokenData = this.parseJwtToken(token);
|
|
|
|
if (!tokenData) {
|
|
return of({ isValid: false, message: 'Invalid token format' });
|
|
}
|
|
|
|
if (this.isTokenExpired(token)) {
|
|
return of({
|
|
isValid: false,
|
|
message: 'This link has expired. Please request a new one.'
|
|
});
|
|
}
|
|
|
|
const user: UserInfo = {
|
|
id: tokenData.userId || tokenData.sub || tokenData.id || '',
|
|
email: tokenData.email || tokenData.email_address || '',
|
|
roles: Array.isArray(tokenData.roles)
|
|
? tokenData.roles
|
|
: tokenData.role ? [tokenData.role] : [],
|
|
languagePreference: tokenData.languagePreference || 'en'
|
|
};
|
|
|
|
return of({
|
|
isValid: true,
|
|
user,
|
|
message: 'Token verified successfully. MFA required.',
|
|
expiresAt: tokenData.exp ? new Date(tokenData.exp * 1000) : undefined,
|
|
requiresMfa: true
|
|
});
|
|
|
|
} catch (error) {
|
|
return of({ isValid: false, message: 'Invalid or corrupted token' });
|
|
}
|
|
}
|
|
|
|
/** Returns true if the JWT's `exp` claim is in the past. */
|
|
isTokenExpired(token: string): boolean {
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
return payload.exp < Math.floor(Date.now() / 1000);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private parseJwtToken(token: string): any | null {
|
|
try {
|
|
const parts = token.split('.');
|
|
if (parts.length !== 3) return null;
|
|
const decoded = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'));
|
|
return JSON.parse(decoded);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|