/** * AuthenticatedUserMapper * * DTO ↔ AuthenticatedUser mapping. Lives next to AuthResponseMapper so the * auth-module response shape is translated in one place — callers (LoginPage, * embed-auth routes, token-refresh interceptor) go through these statics, * never through ad-hoc object literals. * * JWT payloads lack email/name, so embed-auth routes populate them from a * parallel /auth/me call once the guard admits the user. That's why * `fromJwtPayload` has holes — the persisted blob is a best-effort seed * until /auth/me returns. * * (c) 2026 TWWIM UG. All rights reserved. (www.twwim.com) */ import { tokenResponseSchema } from '@archer/api-interface'; import { Currency, isCurrency } from '@archer/domain'; import { decodeJwt, type JwtPayload } from '@/lib/jwt'; import { AuthenticatedUser, type AuthenticatedAccount, type AuthenticatedUserProps } from '@/domain/entities/AuthenticatedUser'; const DEFAULT_ACCOUNT: AuthenticatedAccount = { preferredCurrency: Currency.USD, isProfileComplete: false, missingProfileFields: [], emailVerified: false, legal: { pendingCount: 0, pendingDoctypes: [] }, }; export class AuthenticatedUserMapper { /** * Parse and hydrate from a /auth/login or /auth/refresh response. * Throws a regular Error (not ZodError) so callers can surface a predictable * failure message without crashing on malformed backend payloads. */ static fromTokenResponse(response: unknown): AuthenticatedUser { const validated = tokenResponseSchema.parse(response); if (!validated.user) { throw new Error('Token response missing user data'); } const u = validated.user; if (!u.currentCompanyId) { throw new Error('Token response missing currentCompanyId'); } const account: AuthenticatedAccount = validated.account ? { preferredCurrency: validated.account.preferredCurrency, isProfileComplete: validated.account.isProfileComplete, missingProfileFields: validated.account.missingProfileFields ?? [], emailVerified: validated.account.emailVerified ?? u.emailVerified ?? false, legal: validated.account.legal ?? DEFAULT_ACCOUNT.legal, } : { preferredCurrency: DEFAULT_ACCOUNT.preferredCurrency, isProfileComplete: u.isProfileComplete ?? false, missingProfileFields: u.missingProfileFields ?? [], emailVerified: u.emailVerified ?? false, legal: { pendingCount: 0, pendingDoctypes: [] }, }; // authOrigin lives only on the JWT (not on the response body), so decode // the access token to seed it. Single source of truth — every caller // (login, /auth/refresh interceptor, future auth flows) gets the same // identity shape without each having to remember a manual JWT merge. const jwt = validated.accessToken ? decodeJwt(validated.accessToken) : null; return AuthenticatedUser.create({ id: u.id, email: u.email, name: u.fullName, companyId: u.currentCompanyId, role: u.currentCompanyRole ?? 'worker', authOrigin: jwt?.authOrigin, account, }); } /** * Hydrate from an embed-auth JWT (Shopify/admin/callback routes). JWTs carry * the identity + currentCompanyId; account state defaults and gets refreshed * once the dashboard talks to /companies/:id. */ static fromJwtPayload(payload: JwtPayload, fallbackEmail: string = ''): AuthenticatedUser { if (!payload.currentCompanyId) { throw new Error('JWT missing currentCompanyId'); } return AuthenticatedUser.create({ id: payload.authId ?? payload.sub ?? 'unknown', email: fallbackEmail, name: payload.fullName ?? '', companyId: payload.currentCompanyId, role: payload.currentCompanyRole ?? 'worker', authOrigin: payload.authOrigin, account: { ...DEFAULT_ACCOUNT }, }); } /** Round-trip through localStorage — tolerates unknown keys from older builds. */ static fromStorage(json: string | null): AuthenticatedUser | null { if (!json) return null; try { const raw = JSON.parse(json) as Partial; // Email can be empty when seeded from a JWT-only payload (e.g. the embed // auth.callback route) — /auth/me fills it in later. Identity is id + // companyId; those are the genuine non-optional fields. if (!raw.id || !raw.companyId) return null; const rawAccount = raw.account ?? {}; const currencyCandidate = (rawAccount as AuthenticatedAccount).preferredCurrency; const account: AuthenticatedAccount = { preferredCurrency: isCurrency(currencyCandidate) ? currencyCandidate : DEFAULT_ACCOUNT.preferredCurrency, isProfileComplete: (rawAccount as AuthenticatedAccount).isProfileComplete ?? false, missingProfileFields: (rawAccount as AuthenticatedAccount).missingProfileFields ?? [], emailVerified: (rawAccount as AuthenticatedAccount).emailVerified ?? false, legal: (rawAccount as AuthenticatedAccount).legal ?? DEFAULT_ACCOUNT.legal, }; return AuthenticatedUser.create({ id: raw.id, email: raw.email, name: raw.name ?? '', companyId: raw.companyId, role: raw.role ?? 'worker', authOrigin: raw.authOrigin, account, }); } catch { return null; } } static toStorage(user: AuthenticatedUser): string { return JSON.stringify(user.toJSON()); } }