214 lines
6.2 KiB
TypeScript
214 lines
6.2 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import pb from '../lib/pocketbase';
|
|
import { trackEvent } from '../lib/analytics';
|
|
import { resolveAuthError, type AuthErrorAction } from '../lib/auth-errors';
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
email: string;
|
|
isAdmin: boolean;
|
|
subscription: string;
|
|
newsletter: boolean;
|
|
canSeeListings: boolean;
|
|
}
|
|
|
|
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
|
|
if (typeof record.email !== 'string') {
|
|
throw new Error('PocketBase record missing email field');
|
|
}
|
|
return {
|
|
id: record.id,
|
|
email: record.email,
|
|
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
|
|
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
|
|
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
|
|
canSeeListings: typeof record.can_see_listings === 'boolean' ? record.can_see_listings : false,
|
|
};
|
|
}
|
|
|
|
function authRefreshInvalidated(error: unknown): boolean {
|
|
const status = (error as { status?: unknown })?.status;
|
|
return status === 401 || status === 403;
|
|
}
|
|
|
|
export function useAuth() {
|
|
const { t } = useTranslation();
|
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
|
if (pb.authStore.isValid && pb.authStore.record) {
|
|
return recordToUser(pb.authStore.record);
|
|
}
|
|
return null;
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [errorAction, setErrorAction] = useState<AuthErrorAction>(null);
|
|
|
|
// Sync with authStore changes (cross-tab, external updates)
|
|
useEffect(() => {
|
|
const unsubscribe = pb.authStore.onChange(() => {
|
|
if (pb.authStore.isValid && pb.authStore.record) {
|
|
setUser(recordToUser(pb.authStore.record));
|
|
} else {
|
|
setUser(null);
|
|
}
|
|
});
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
const login = useCallback(
|
|
async (email: string, password: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setErrorAction(null);
|
|
try {
|
|
const result = await pb.collection('users').authWithPassword(email, password);
|
|
setUser(recordToUser(result.record));
|
|
trackEvent('Login', { method: 'email' });
|
|
} catch (err) {
|
|
const { message, action } = resolveAuthError(err, 'login', t);
|
|
setError(message);
|
|
setErrorAction(action);
|
|
throw err;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const register = useCallback(
|
|
async (email: string, password: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setErrorAction(null);
|
|
try {
|
|
// Step 1: create the account. A failure here means nothing was created,
|
|
// so surface a friendly, field-aware message (e.g. "email already exists").
|
|
try {
|
|
await pb.collection('users').create({
|
|
email,
|
|
password,
|
|
passwordConfirm: password,
|
|
newsletter: true,
|
|
});
|
|
} catch (err) {
|
|
const { message, action } = resolveAuthError(err, 'register', t);
|
|
setError(message);
|
|
setErrorAction(action);
|
|
throw err;
|
|
}
|
|
// Step 2: the account now EXISTS. If auto-login fails (commonly a 429
|
|
// rate limit from the two back-to-back writes) the account is NOT
|
|
// orphaned: tell the user it was created and let them log in with the
|
|
// same credentials, instead of a misleading "registration failed".
|
|
try {
|
|
const result = await pb.collection('users').authWithPassword(email, password);
|
|
setUser(recordToUser(result.record));
|
|
trackEvent('Register');
|
|
} catch (err) {
|
|
const status = (err as { status?: number } | null)?.status;
|
|
setError(
|
|
status === 429
|
|
? t('auth.accountCreatedRateLimited')
|
|
: t('auth.accountCreatedPleaseLogIn')
|
|
);
|
|
setErrorAction('switchToLogin');
|
|
throw err;
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const loginWithOAuth = useCallback(
|
|
async (provider: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setErrorAction(null);
|
|
try {
|
|
const result = await pb.collection('users').authWithOAuth2({
|
|
provider,
|
|
createData: { newsletter: true },
|
|
});
|
|
setUser(recordToUser(result.record));
|
|
trackEvent('Login', { method: provider });
|
|
} catch (err) {
|
|
const { message, action } = resolveAuthError(err, 'oauth', t);
|
|
setError(message);
|
|
setErrorAction(action);
|
|
throw err;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const logout = useCallback(() => {
|
|
trackEvent('Logout');
|
|
pb.authStore.clear();
|
|
setUser(null);
|
|
}, []);
|
|
|
|
const requestPasswordReset = useCallback(
|
|
async (email: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setErrorAction(null);
|
|
try {
|
|
await pb.collection('users').requestPasswordReset(email);
|
|
} catch (err) {
|
|
const { message, action } = resolveAuthError(err, 'reset', t);
|
|
setError(message);
|
|
setErrorAction(action);
|
|
throw err;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const refreshAuth = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const result = await pb.collection('users').authRefresh();
|
|
const refreshedUser = recordToUser(result.record);
|
|
setUser(refreshedUser);
|
|
return refreshedUser;
|
|
} catch (err) {
|
|
// Only clear auth on explicit token rejection. Network and 5xx failures
|
|
// should not log users out during background refresh.
|
|
if (authRefreshInvalidated(err)) {
|
|
pb.authStore.clear();
|
|
setUser(null);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const clearError = useCallback(() => {
|
|
setError(null);
|
|
setErrorAction(null);
|
|
}, []);
|
|
|
|
return {
|
|
user,
|
|
loading,
|
|
error,
|
|
errorAction,
|
|
login,
|
|
register,
|
|
loginWithOAuth,
|
|
logout,
|
|
requestPasswordReset,
|
|
refreshAuth,
|
|
clearError,
|
|
};
|
|
}
|