Follow-up to the comprehensive payment-system review. Fixes the issues the review found in the initial integration, plus the rough edges it introduced. Money-safety: - Replay-by-key now replays the FULL original request verbatim from a stored square_request_snapshot, so a retained idempotency key returns the original payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge). - Dev mock mirrors real Square for unknown-key replays: ccof: saved-card sources are charged and rescued; spent cnon: nonces surface ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.) - Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales claw back gift-card funding; event-type strings match Square's real catalog. - Expired-gift-card cancellation refunds set creditFailed (never a phantom 'completed' refund); cancellation refunds lock all payment rows ascending. - Sweep never rescue-completes a gift-card purchase without delivering the card. - Tip no-client-key fallback is a deterministic count-based key under the booking advisory lock (retry-safe, distinct tips don't collapse). - M-cap subtracts completed refunds, clamped to [0, total]. 2FA (PSD2 SCA stand-in) for online saved-card payments: - Full feature: status/setup/verify/disable endpoints, gating helper wired into all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account admin-tab settings UI, frontend gating across all payment surfaces. - Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env. - Verify is brute-force hardened (5-attempt lockout, timing-safe compare); plaintext codes only logged when enforcement is off (dev). - GDPR: anonymize_user also scrubs 2FA columns and staff notes. Infra/docs: - nginx: /api/ response cache removed (cross-user disclosure); port 80 redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS; separate webhook rate-limit zone. - Schema: users 2FA columns; payments/till_sales square_source_id + square_request_snapshot. - Legal docs: gift-card cooling-off, international-transfers section, tips policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected. - Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26 packages green, 2,142 tests, svelte-check clean.
245 lines
5.2 KiB
TypeScript
245 lines
5.2 KiB
TypeScript
// src/lib/stores/auth.svelte.ts
|
|
import { browser } from '$app/environment';
|
|
import { goto } from '$app/navigation';
|
|
|
|
type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
|
|
|
|
interface DecodedToken {
|
|
user_id: string;
|
|
role: UserRole;
|
|
exp: number;
|
|
}
|
|
|
|
export interface User {
|
|
id: string;
|
|
email: string;
|
|
role: UserRole;
|
|
firstName: string;
|
|
lastName: string;
|
|
phone?: string;
|
|
dateOfBirth?: string;
|
|
loyaltyStamps?: number;
|
|
referralCode?: string;
|
|
referralCodeUses?: number;
|
|
referralSavings?: number;
|
|
profilePicUrl?: string;
|
|
previousFirstName?: string;
|
|
previousLastName?: string;
|
|
twoFactorEnabled?: boolean;
|
|
twoFactorRequired?: boolean;
|
|
twoFactorMethod?: string;
|
|
}
|
|
|
|
class AuthStore {
|
|
private token = $state<string | null>(null);
|
|
private user = $state<User | null>(null);
|
|
private loading = $state(true);
|
|
|
|
constructor() {
|
|
if (browser) {
|
|
this.initializeAuth();
|
|
// Check token refresh every 2 minutes
|
|
// (must be shorter than the 5-minute threshold so the
|
|
// refresh check fires before the token actually expires)
|
|
setInterval(
|
|
() => {
|
|
if (this.token) {
|
|
this.refreshTokenIfNeeded();
|
|
}
|
|
},
|
|
2 * 60 * 1000
|
|
);
|
|
}
|
|
}
|
|
|
|
get isAuthenticated() {
|
|
return this.token !== null && this.user !== null;
|
|
}
|
|
|
|
get currentUser() {
|
|
return this.user;
|
|
}
|
|
|
|
get currentToken() {
|
|
return this.token;
|
|
}
|
|
|
|
get isLoading() {
|
|
return this.loading;
|
|
}
|
|
|
|
get hasLoaded() {
|
|
return !this.loading;
|
|
}
|
|
|
|
private initializeAuth() {
|
|
const storedToken = localStorage.getItem('authToken');
|
|
if (storedToken) {
|
|
const decoded = this.decodeToken(storedToken);
|
|
if (decoded && !this.isTokenExpired(decoded)) {
|
|
this.token = storedToken;
|
|
// Set basic user info from token
|
|
this.user = {
|
|
id: decoded.user_id,
|
|
role: decoded.role,
|
|
email: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
twoFactorEnabled: false,
|
|
twoFactorRequired: false
|
|
};
|
|
|
|
// Refresh first so any JTI invalidation from rotation
|
|
// happens before other requests use this token.
|
|
// Then fetch profile with the (potentially refreshed) token.
|
|
this.refreshTokenIfNeeded().then(() => {
|
|
this.fetchUserProfile();
|
|
});
|
|
} else {
|
|
this.clearAuth();
|
|
}
|
|
}
|
|
this.loading = false;
|
|
}
|
|
|
|
private decodeToken(token: string): DecodedToken | null {
|
|
try {
|
|
const payload = token.split('.')[1];
|
|
const decoded = JSON.parse(atob(payload));
|
|
return decoded;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private isTokenExpired(decoded: DecodedToken): boolean {
|
|
return decoded.exp * 1000 < Date.now();
|
|
}
|
|
|
|
// Simple setters - UI handles the API calls
|
|
setToken(token: string) {
|
|
this.token = token;
|
|
if (browser) {
|
|
localStorage.setItem('authToken', token);
|
|
}
|
|
|
|
// Decode to get basic info
|
|
const decoded = this.decodeToken(token);
|
|
if (decoded) {
|
|
this.user = {
|
|
id: decoded.user_id,
|
|
role: decoded.role,
|
|
email: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
twoFactorEnabled: false,
|
|
twoFactorRequired: false
|
|
};
|
|
this.fetchUserProfile();
|
|
}
|
|
}
|
|
|
|
private async fetchUserProfile() {
|
|
if (!this.token) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/user/profile', {
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch profile');
|
|
}
|
|
|
|
const userData = await response.json();
|
|
this.user = userData;
|
|
} catch {
|
|
this.clearAuth();
|
|
}
|
|
}
|
|
|
|
// inside AuthStore
|
|
logout = async () => {
|
|
if (this.token) {
|
|
try {
|
|
await fetch('/api/logout', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${this.token}` }
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
} catch (e) {
|
|
// Ignore network errors - still clear local state
|
|
}
|
|
}
|
|
this.clearAuth();
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
goto('/', { invalidateAll: true });
|
|
};
|
|
|
|
private clearAuth() {
|
|
this.token = null;
|
|
this.user = null;
|
|
if (browser) {
|
|
localStorage.removeItem('authToken');
|
|
}
|
|
}
|
|
|
|
hasRole(requiredRole: UserRole | UserRole[]): boolean {
|
|
if (!this.user) return false;
|
|
|
|
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole];
|
|
return roles.includes(this.user.role);
|
|
}
|
|
|
|
isAdmin(): boolean {
|
|
return this.hasRole('admin');
|
|
}
|
|
|
|
isVerified(): boolean {
|
|
return this.hasRole(['verified_email', 'admin']);
|
|
}
|
|
|
|
// Refresh token before it expires
|
|
async refreshTokenIfNeeded() {
|
|
if (!this.token) return;
|
|
|
|
const decoded = this.decodeToken(this.token);
|
|
if (!decoded) {
|
|
this.clearAuth();
|
|
return;
|
|
}
|
|
|
|
// Refresh if token expires in less than 5 minutes
|
|
// (1-hour token lifetime from backend)
|
|
const fiveMinutes = 5 * 60 * 1000;
|
|
if (decoded.exp * 1000 - Date.now() < fiveMinutes) {
|
|
try {
|
|
const response = await fetch('/api/refresh-token', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.setToken(data.token);
|
|
} else {
|
|
this.clearAuth();
|
|
}
|
|
} catch (error) {
|
|
console.error('Token refresh failed:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Manual refresh method
|
|
async refreshProfile() {
|
|
await this.fetchUserProfile();
|
|
}
|
|
}
|
|
|
|
export const authStore = new AuthStore();
|