fix(mobile): harden auth session bootstrap
This commit is contained in:
parent
e1cc7feb5b
commit
3d02deb14c
23
mobile/src/lib/auth-helpers.test.ts
Normal file
23
mobile/src/lib/auth-helpers.test.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { PRODUCT_ID } from '../api/config';
|
||||||
|
import { mmkvStorage } from '../store/mmkv-storage';
|
||||||
|
import { AUTH_ACCESS_TOKEN_KEY, getAccessToken } from './auth-helpers';
|
||||||
|
|
||||||
|
describe('mobile auth helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mmkvStorage.removeItem(`${PRODUCT_ID}_access_token`);
|
||||||
|
mmkvStorage.removeItem(`${PRODUCT_ID}-auth-token`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the shared auth-client access token key', () => {
|
||||||
|
mmkvStorage.setItem(AUTH_ACCESS_TOKEN_KEY, 'platform-token');
|
||||||
|
|
||||||
|
expect(getAccessToken()).toBe('platform-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read the legacy mobile-only token key', () => {
|
||||||
|
mmkvStorage.setItem(`${PRODUCT_ID}_access_token`, 'legacy-token');
|
||||||
|
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { PRODUCT_ID } from '../api/config';
|
import { PRODUCT_ID } from '../api/config';
|
||||||
import { mmkvStorage } from '../store/mmkv-storage';
|
import { mmkvStorage } from '../store/mmkv-storage';
|
||||||
|
|
||||||
|
export const AUTH_ACCESS_TOKEN_KEY = `${PRODUCT_ID}-auth-token`;
|
||||||
|
|
||||||
export function getAccessToken(): string | null {
|
export function getAccessToken(): string | null {
|
||||||
return mmkvStorage.getItem(`${PRODUCT_ID}_access_token`);
|
return mmkvStorage.getItem(AUTH_ACCESS_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { PRODUCT_ID } from '../api/config';
|
||||||
|
import { mmkvStorage } from './mmkv-storage';
|
||||||
|
|
||||||
const isAuthenticatedMock = vi.fn();
|
const isAuthenticatedMock = vi.fn();
|
||||||
const getMeMock = vi.fn();
|
const getMeMock = vi.fn();
|
||||||
const loginMock = vi.fn();
|
const loginMock = vi.fn();
|
||||||
const registerMock = vi.fn();
|
const registerMock = vi.fn();
|
||||||
const clearTokensMock = vi.fn();
|
const clearTokensMock = vi.fn();
|
||||||
|
const refreshAccessTokenMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../api/auth', () => ({
|
vi.mock('../api/auth', () => ({
|
||||||
getAuthClient: () => ({
|
getAuthClient: () => ({
|
||||||
@ -12,6 +15,7 @@ vi.mock('../api/auth', () => ({
|
|||||||
getMe: getMeMock,
|
getMe: getMeMock,
|
||||||
login: loginMock,
|
login: loginMock,
|
||||||
register: registerMock,
|
register: registerMock,
|
||||||
|
refreshAccessToken: refreshAccessTokenMock,
|
||||||
clearTokens: clearTokensMock,
|
clearTokens: clearTokensMock,
|
||||||
getAccessToken: vi.fn(() => null),
|
getAccessToken: vi.fn(() => null),
|
||||||
}),
|
}),
|
||||||
@ -20,6 +24,7 @@ vi.mock('../api/auth', () => ({
|
|||||||
import { useAuthStore } from './auth-store';
|
import { useAuthStore } from './auth-store';
|
||||||
|
|
||||||
function resetStore() {
|
function resetStore() {
|
||||||
|
mmkvStorage.removeItem(`${PRODUCT_ID}-auth-email`);
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
hasBootstrapped: false,
|
hasBootstrapped: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
@ -41,67 +46,143 @@ describe('useAuthStore', () => {
|
|||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
expect(useAuthStore.getState().email).toBe('test@example.com');
|
expect(useAuthStore.getState().email).toBe('test@example.com');
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBe('test@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bootstrap sets unauthenticated when no token', async () => {
|
it('bootstrap sets unauthenticated when no token', async () => {
|
||||||
isAuthenticatedMock.mockReturnValue(false);
|
isAuthenticatedMock.mockReturnValue(false);
|
||||||
|
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'cached@example.com');
|
||||||
await useAuthStore.getState().bootstrap();
|
await useAuthStore.getState().bootstrap();
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
expect(useAuthStore.getState().email).toBeNull();
|
expect(useAuthStore.getState().email).toBeNull();
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bootstrap clears tokens on getMe failure', async () => {
|
it('bootstrap preserves a valid local session during offline startup', async () => {
|
||||||
isAuthenticatedMock.mockReturnValue(true);
|
isAuthenticatedMock.mockReturnValue(true);
|
||||||
getMeMock.mockRejectedValueOnce(new Error('expired'));
|
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'cached@example.com');
|
||||||
|
getMeMock.mockRejectedValueOnce(new Error('Network request failed'));
|
||||||
|
await useAuthStore.getState().bootstrap();
|
||||||
|
expect(clearTokensMock).not.toHaveBeenCalled();
|
||||||
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
expect(useAuthStore.getState().email).toBe('cached@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bootstrap refreshes an expired token before clearing the session', async () => {
|
||||||
|
isAuthenticatedMock.mockReturnValue(true);
|
||||||
|
getMeMock.mockRejectedValueOnce(new Error('HTTP 401'));
|
||||||
|
refreshAccessTokenMock.mockResolvedValueOnce(true);
|
||||||
|
getMeMock.mockResolvedValueOnce({ email: 'fresh@example.com' });
|
||||||
|
await useAuthStore.getState().bootstrap();
|
||||||
|
expect(refreshAccessTokenMock).toHaveBeenCalled();
|
||||||
|
expect(clearTokensMock).not.toHaveBeenCalled();
|
||||||
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
expect(useAuthStore.getState().email).toBe('fresh@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bootstrap clears tokens on non-refreshable getMe failure', async () => {
|
||||||
|
isAuthenticatedMock.mockReturnValue(true);
|
||||||
|
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'cached@example.com');
|
||||||
|
getMeMock.mockRejectedValueOnce(new Error('invalid account'));
|
||||||
await useAuthStore.getState().bootstrap();
|
await useAuthStore.getState().bootstrap();
|
||||||
expect(clearTokensMock).toHaveBeenCalled();
|
expect(clearTokensMock).toHaveBeenCalled();
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('signIn sets authenticated on success', async () => {
|
it('signIn sets authenticated on success', async () => {
|
||||||
loginMock.mockResolvedValueOnce(undefined);
|
loginMock.mockResolvedValueOnce({
|
||||||
|
accessToken: 'access',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
user: { email: 'test@example.com' },
|
||||||
|
});
|
||||||
const ok = await useAuthStore.getState().signIn('test@example.com', 'pass');
|
const ok = await useAuthStore.getState().signIn('test@example.com', 'pass');
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
|
expect(loginMock).toHaveBeenCalledWith('test@example.com', 'pass');
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
expect(useAuthStore.getState().email).toBe('test@example.com');
|
expect(useAuthStore.getState().email).toBe('test@example.com');
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signIn returns false when the platform requires MFA', async () => {
|
||||||
|
loginMock.mockResolvedValueOnce({ mfaRequired: true, mfaChallenge: 'challenge', methods: ['totp'] });
|
||||||
|
const ok = await useAuthStore.getState().signIn('test@example.com', 'pass');
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(clearTokensMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('signIn returns false on failure', async () => {
|
it('signIn returns false on failure', async () => {
|
||||||
loginMock.mockRejectedValueOnce(new Error('bad credentials'));
|
loginMock.mockRejectedValueOnce(new Error('bad credentials'));
|
||||||
|
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'old@example.com');
|
||||||
const ok = await useAuthStore.getState().signIn('bad@example.com', 'wrong');
|
const ok = await useAuthStore.getState().signIn('bad@example.com', 'wrong');
|
||||||
expect(ok).toBe(false);
|
expect(ok).toBe(false);
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
expect(clearTokensMock).toHaveBeenCalled();
|
expect(clearTokensMock).toHaveBeenCalled();
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('register sets authenticated on success', async () => {
|
it('register sets authenticated on success', async () => {
|
||||||
registerMock.mockResolvedValueOnce(undefined);
|
registerMock.mockResolvedValueOnce({
|
||||||
|
accessToken: 'access',
|
||||||
|
refreshToken: 'refresh',
|
||||||
|
user: { email: 'new@example.com' },
|
||||||
|
});
|
||||||
const ok = await useAuthStore.getState().register('new@example.com', 'pass', 'New User');
|
const ok = await useAuthStore.getState().register('new@example.com', 'pass', 'New User');
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
|
expect(registerMock).toHaveBeenCalledWith('new@example.com', 'pass', 'New User');
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
expect(useAuthStore.getState().email).toBe('new@example.com');
|
expect(useAuthStore.getState().email).toBe('new@example.com');
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBe('new@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('register returns false on failure', async () => {
|
it('register returns false on failure', async () => {
|
||||||
registerMock.mockRejectedValueOnce(new Error('register failed'));
|
registerMock.mockRejectedValueOnce(new Error('register failed'));
|
||||||
|
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'old@example.com');
|
||||||
const ok = await useAuthStore.getState().register('bad@example.com', 'wrong', 'Bad');
|
const ok = await useAuthStore.getState().register('bad@example.com', 'wrong', 'Bad');
|
||||||
expect(ok).toBe(false);
|
expect(ok).toBe(false);
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
expect(clearTokensMock).toHaveBeenCalled();
|
expect(clearTokensMock).toHaveBeenCalled();
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshSession keeps authenticated state on refresh success', async () => {
|
||||||
|
isAuthenticatedMock.mockReturnValue(true);
|
||||||
|
refreshAccessTokenMock.mockResolvedValueOnce(true);
|
||||||
|
getMeMock.mockResolvedValueOnce({ email: 'fresh@example.com' });
|
||||||
|
const ok = await useAuthStore.getState().refreshSession();
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(refreshAccessTokenMock).toHaveBeenCalled();
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
expect(useAuthStore.getState().email).toBe('fresh@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshSession clears state when refresh fails', async () => {
|
||||||
|
isAuthenticatedMock.mockReturnValue(true);
|
||||||
|
refreshAccessTokenMock.mockResolvedValueOnce(false);
|
||||||
|
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'old@example.com');
|
||||||
|
const ok = await useAuthStore.getState().refreshSession();
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('signOut clears state', () => {
|
it('signOut clears state', () => {
|
||||||
|
mmkvStorage.setItem(`${PRODUCT_ID}-auth-email`, 'test@example.com');
|
||||||
useAuthStore.setState({ hasBootstrapped: true, isAuthenticated: true, email: 'test@example.com' });
|
useAuthStore.setState({ hasBootstrapped: true, isAuthenticated: true, email: 'test@example.com' });
|
||||||
useAuthStore.getState().signOut();
|
useAuthStore.getState().signOut();
|
||||||
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
expect(useAuthStore.getState().hasBootstrapped).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
expect(useAuthStore.getState().email).toBeNull();
|
expect(useAuthStore.getState().email).toBeNull();
|
||||||
expect(clearTokensMock).toHaveBeenCalled();
|
expect(clearTokensMock).toHaveBeenCalled();
|
||||||
|
expect(mmkvStorage.getItem(`${PRODUCT_ID}-auth-email`)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,52 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { getAuthClient } from '../api/auth';
|
import { getAuthClient } from '../api/auth';
|
||||||
|
import { PRODUCT_ID } from '../api/config';
|
||||||
|
import { mmkvStorage } from './mmkv-storage';
|
||||||
|
|
||||||
|
const AUTH_EMAIL_CACHE_KEY = `${PRODUCT_ID}-auth-email`;
|
||||||
|
|
||||||
|
function getCachedEmail(): string | null {
|
||||||
|
return mmkvStorage.getItem(AUTH_EMAIL_CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedEmail(email: string): void {
|
||||||
|
mmkvStorage.setItem(AUTH_EMAIL_CACHE_KEY, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCachedEmail(): void {
|
||||||
|
mmkvStorage.removeItem(AUTH_EMAIL_CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOfflineStartupError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes('network') ||
|
||||||
|
message.includes('failed to fetch') ||
|
||||||
|
message.includes('fetch failed') ||
|
||||||
|
message.includes('offline') ||
|
||||||
|
message.includes('timeout') ||
|
||||||
|
message.includes('abort')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRefreshableAuthError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes('http 401') ||
|
||||||
|
message.includes('http 403') ||
|
||||||
|
message.includes('unauthorized') ||
|
||||||
|
message.includes('forbidden') ||
|
||||||
|
message.includes('expired')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type AuthState = {
|
export type AuthState = {
|
||||||
hasBootstrapped: boolean;
|
hasBootstrapped: boolean;
|
||||||
@ -7,6 +54,7 @@ export type AuthState = {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
bootstrap: () => Promise<void>;
|
bootstrap: () => Promise<void>;
|
||||||
|
refreshSession: () => Promise<boolean>;
|
||||||
signIn: (email: string, password: string) => Promise<boolean>;
|
signIn: (email: string, password: string) => Promise<boolean>;
|
||||||
register: (email: string, password: string, displayName: string) => Promise<boolean>;
|
register: (email: string, password: string, displayName: string) => Promise<boolean>;
|
||||||
signOut: () => void;
|
signOut: () => void;
|
||||||
@ -20,6 +68,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
async bootstrap() {
|
async bootstrap() {
|
||||||
const client = getAuthClient();
|
const client = getAuthClient();
|
||||||
if (!client.isAuthenticated()) {
|
if (!client.isAuthenticated()) {
|
||||||
|
clearCachedEmail();
|
||||||
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -28,21 +77,86 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const me = await client.getMe();
|
const me = await client.getMe();
|
||||||
|
setCachedEmail(me.email);
|
||||||
set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isOfflineStartupError(error) && client.isAuthenticated()) {
|
||||||
|
set({
|
||||||
|
hasBootstrapped: true,
|
||||||
|
isAuthenticated: true,
|
||||||
|
email: getCachedEmail(),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshableAuthError(error) && (await client.refreshAccessToken())) {
|
||||||
|
try {
|
||||||
|
const me = await client.getMe();
|
||||||
|
setCachedEmail(me.email);
|
||||||
|
set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false });
|
||||||
|
return;
|
||||||
|
} catch (refreshError) {
|
||||||
|
if (isOfflineStartupError(refreshError) && client.isAuthenticated()) {
|
||||||
|
set({
|
||||||
|
hasBootstrapped: true,
|
||||||
|
isAuthenticated: true,
|
||||||
|
email: getCachedEmail(),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
client.clearTokens();
|
client.clearTokens();
|
||||||
|
clearCachedEmail();
|
||||||
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async refreshSession() {
|
||||||
|
const client = getAuthClient();
|
||||||
|
if (!client.isAuthenticated()) {
|
||||||
|
clearCachedEmail();
|
||||||
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
const refreshed = await client.refreshAccessToken();
|
||||||
|
if (!refreshed) {
|
||||||
|
clearCachedEmail();
|
||||||
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const me = await client.getMe();
|
||||||
|
setCachedEmail(me.email);
|
||||||
|
set({ hasBootstrapped: true, isAuthenticated: true, email: me.email, isLoading: false });
|
||||||
|
} catch {
|
||||||
|
set({ hasBootstrapped: true, isAuthenticated: true, email: getCachedEmail(), isLoading: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
async signIn(email: string, password: string) {
|
async signIn(email: string, password: string) {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getAuthClient().login(email, password);
|
const result = await getAuthClient().login(email, password);
|
||||||
set({ hasBootstrapped: true, isAuthenticated: true, email, isLoading: false });
|
if ('mfaRequired' in result) {
|
||||||
|
getAuthClient().clearTokens();
|
||||||
|
clearCachedEmail();
|
||||||
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setCachedEmail(result.user.email);
|
||||||
|
set({ hasBootstrapped: true, isAuthenticated: true, email: result.user.email, isLoading: false });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
getAuthClient().clearTokens();
|
getAuthClient().clearTokens();
|
||||||
|
clearCachedEmail();
|
||||||
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -51,17 +165,20 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getAuthClient().register(email, password, displayName);
|
const result = await getAuthClient().register(email, password, displayName);
|
||||||
set({ hasBootstrapped: true, isAuthenticated: true, email, isLoading: false });
|
setCachedEmail(result.user.email);
|
||||||
|
set({ hasBootstrapped: true, isAuthenticated: true, email: result.user.email, isLoading: false });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
getAuthClient().clearTokens();
|
getAuthClient().clearTokens();
|
||||||
|
clearCachedEmail();
|
||||||
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signOut() {
|
signOut() {
|
||||||
getAuthClient().clearTokens();
|
getAuthClient().clearTokens();
|
||||||
|
clearCachedEmail();
|
||||||
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
set({ hasBootstrapped: true, isAuthenticated: false, email: null, isLoading: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user