Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/mobile/src/auth/SignInSheet.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it, jest } from '@jest/globals';
// After `@jest/globals`: the mock of gorhom, hoisted, needs `jest`.
import { BottomSheetModal } from '@gorhom/bottom-sheet';
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import { createFakeAuth } from '../test/fakeAuth';
import { createSignInFlow, type SignInFlowDeps } from './flow';
Expand All @@ -24,6 +26,8 @@ async function renderSheet(overrides: Partial<SignInFlowDeps> = {}) {
const flow = createSignInFlow({
provider: auth.provider,
isSignedIn: () => false,
onSignedIn: () => undefined,
endSession: async () => undefined,
getFirstName: async () => null,
saveFirstName: async () => undefined,
pendingEmail: { get: () => null, set: () => undefined },
Expand All @@ -37,6 +41,26 @@ async function renderSheet(overrides: Partial<SignInFlowDeps> = {}) {
}

describe('SignInSheet', () => {
it('opens again after a close on the veil, without dismissing the sheet twice', async () => {
let onDismiss: () => void = () => undefined;
const present = jest
.spyOn(BottomSheetModal.prototype, 'present')
.mockImplementation(function (this: { props: { onDismiss: () => void } }) {
onDismiss = this.props.onDismiss;
});
const dismiss = jest.spyOn(BottomSheetModal.prototype, 'dismiss');
const { flow } = await renderSheet();
await act(() => flow.requireAccount('profile', () => undefined));
// gorhom closes the sheet on the veil, then tells it.
await act(() => onDismiss());
expect(flow.store.getState().isOpen).toBe(false);
expect(dismiss).not.toHaveBeenCalled();
await act(() => flow.requireAccount('plan', () => undefined));
expect(present).toHaveBeenCalledTimes(2);
present.mockRestore();
dismiss.mockRestore();
});

it('says the action waiting, with Google, the e-mail and the conditions', async () => {
const { flow } = await renderSheet();
await act(() => flow.requireAccount('go', () => undefined));
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/auth/SignInSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export function SignInSheet({ flow }: { flow: SignInFlow }) {
handleComponent={parts.handle}
onDismiss={() => {
setModalSheetOpen(false);
// Already dismissed: the closed state must not dismiss it again, which would keep it
// from presenting at the next opening (gorhom).
wasOpen.current = false;
// Closed by the user: by a swipe or on the veil.
if (flow.store.getState().isOpen) {
flow.dismiss();
Expand Down
35 changes: 35 additions & 0 deletions apps/mobile/src/auth/flow.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ApiRequestError } from '@widoo/api-client';
import { describe, expect, it, vi } from 'vitest';
import { createFakeAuth } from '../test/fakeAuth';
import { createSignInFlow, type PendingEmail, type SignInFlowDeps } from './flow';
Expand All @@ -17,6 +18,10 @@ function setup(overrides: Partial<SignInFlowDeps> = {}) {
const deps: SignInFlowDeps = {
provider: auth.provider,
isSignedIn: () => signedIn,
onSignedIn: vi.fn(),
endSession: vi.fn(async () => {
signedIn = false;
}),
getFirstName: vi.fn(async () => 'Camille'),
saveFirstName: vi.fn(async () => undefined),
pendingEmail: { get: () => kept, set: (value) => (kept = value) },
Expand Down Expand Up @@ -55,6 +60,7 @@ describe('sign-in flow', () => {
expect(deps.trackPrompted).toHaveBeenCalledWith('go');
auth.provider.signInWithGoogle = async () => googleResult;
await flow.signInWithGoogle();
expect(deps.onSignedIn).toHaveBeenCalledWith(googleResult.user);
expect(deps.trackCompleted).toHaveBeenCalledWith(googleResult, 'go');
expect(action).toHaveBeenCalledTimes(1);
expect(state().isOpen).toBe(false);
Expand Down Expand Up @@ -165,4 +171,33 @@ describe('sign-in flow', () => {
expect(state()).toMatchObject({ isOpen: true, step: 'welcome' });
expect(action).not.toHaveBeenCalled();
});

it('opens the sheet for an account deleted, never runs the action as signed in', async () => {
const deleted = new ApiRequestError('http', 401, {
code: 'unauthorized',
message: 'Account deleted',
});
const { flow, deps, signIn, state } = setup({
getFirstName: vi.fn(async () => Promise.reject(deleted)),
});
signIn();
const action = vi.fn();
await flow.requireAccount('plan', action);
expect(deps.endSession).toHaveBeenCalledTimes(1);
expect(state()).toMatchObject({ isOpen: true, intent: 'plan', step: 'choices' });
expect(deps.trackPrompted).toHaveBeenCalledWith('plan');
expect(action).not.toHaveBeenCalled();
});

it('runs the action when /me is out of reach, without ending the session', async () => {
const offline = new ApiRequestError('network', null, null);
const { flow, deps, signIn } = setup({
getFirstName: vi.fn(async () => Promise.reject(offline)),
});
signIn();
const action = vi.fn();
await flow.requireAccount('plan', action);
expect(action).toHaveBeenCalledTimes(1);
expect(deps.endSession).not.toHaveBeenCalled();
});
});
28 changes: 22 additions & 6 deletions apps/mobile/src/auth/flow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { isAccountDeleted } from '@widoo/api-client';
import type { AnalyticsEventArgs } from '@widoo/shared';
import { createStore } from 'zustand/vanilla';
import { SignInError, type AuthProvider, type SignInFailure, type SignInResult } from './provider';
import {
SignInError,
type AuthProvider,
type AuthUser,
type SignInFailure,
type SignInResult,
} from './provider';

/** Why the sheet opens: the action waiting for an account (`auth_prompted`, D-033). */
export type AuthIntent = AnalyticsEventArgs<'auth_prompted'>[0]['intent'];
Expand Down Expand Up @@ -37,6 +44,10 @@ export interface PendingEmail {
export interface SignInFlowDeps {
provider: AuthProvider;
isSignedIn: () => boolean;
/** A sign-in went through: the session takes its user before `/me` is asked. */
onSignedIn: (user: AuthUser) => void;
/** The account was deleted (401 « Account deleted »): the app becomes anonymous. */
endSession: () => Promise<void>;
/** `firstName` of `/me`, created on the first call; null when the account has none yet. */
getFirstName: () => Promise<string | null>;
saveFirstName: (firstName: string) => Promise<void>;
Expand Down Expand Up @@ -118,6 +129,7 @@ export function createSignInFlow(deps: SignInFlowDeps) {
async function signedIn(result: SignInResult) {
const intent = store.getState().intent ?? deps.pendingEmail.get()?.intent ?? null;
deps.pendingEmail.set(null);
deps.onSignedIn(result.user);
if (intent) {
deps.trackCompleted(result, intent);
}
Expand Down Expand Up @@ -160,16 +172,20 @@ export function createSignInFlow(deps: SignInFlowDeps) {
* sheet otherwise.
*/
async requireAccount(intent: AuthIntent, action: () => void) {
if (deps.isSignedIn()) {
if (hasFirstName === null) {
try {
hasFirstName = (await deps.getFirstName()) !== null;
} catch {
if (deps.isSignedIn() && hasFirstName === null) {
try {
hasFirstName = (await deps.getFirstName()) !== null;
} catch (error) {
if (!isAccountDeleted(error)) {
// Offline: the action tells its own failure; the name waits for the next one.
action();
return;
}
// A deleted account is no account: anonymous, the sheet opens below.
await deps.endSession();
}
}
if (deps.isSignedIn()) {
if (hasFirstName) {
action();
return;
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react-native';
import { useStore } from 'zustand';
import { analytics } from '../analytics';
import { queryClient } from '../api/query-client';
Expand All @@ -14,10 +15,11 @@ export const session = createSession(authProvider, {
onSignedInChange: () => {
void resetRightsDependentQueries(queryClient);
},
onSignOutFailed: (error) => Sentry.captureException(error),
});

session.store.subscribe((state) => analytics.setAuthenticated(state.status === 'signedIn'));
signOutOnDeletedAccount(queryClient, session.signOut);
signOutOnDeletedAccount(queryClient, session.endSession);

export function useSession<T>(selector: (state: SessionState) => T): T {
return useStore(session.store, selector);
Expand Down
10 changes: 5 additions & 5 deletions apps/mobile/src/auth/rights.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,17 @@ describe('deleted account', () => {
})
.catch(() => undefined);

it('signs out on the 401 of a deleted account only', async () => {
const signOut = vi.fn(async () => undefined);
const unsubscribe = signOutOnDeletedAccount(client, signOut);
it('ends the session on the 401 of a deleted account only', async () => {
const endSession = vi.fn(async () => undefined);
const unsubscribe = signOutOnDeletedAccount(client, endSession);
await failWith(
new ApiRequestError('http', 401, { code: 'unauthorized', message: 'Invalid token' }),
);
expect(signOut).not.toHaveBeenCalled();
expect(endSession).not.toHaveBeenCalled();
await failWith(
new ApiRequestError('http', 401, { code: 'unauthorized', message: 'Account deleted' }),
);
expect(signOut).toHaveBeenCalledTimes(1);
expect(endSession).toHaveBeenCalledTimes(1);
unsubscribe();
});
});
9 changes: 5 additions & 4 deletions apps/mobile/src/auth/rights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,18 @@ export function resetRightsDependentQueries(client: QueryClient): Promise<void>
}

/**
* Signs out when the API says the account was deleted (401 « Account deleted »): its token is
* refused for good, refreshing it would not help.
* Ends the session when the API says the account was deleted (401 « Account deleted »): its token
* is refused for good, refreshing it would not help. `endSession` makes the app anonymous at once,
* which disables the account queries, and signs out only once however many queries fail.
*/
export function signOutOnDeletedAccount(client: QueryClient, signOut: () => Promise<void>) {
export function signOutOnDeletedAccount(client: QueryClient, endSession: () => Promise<void>) {
return client.getQueryCache().subscribe((event) => {
if (
event.type === 'updated' &&
event.action.type === 'error' &&
isAccountDeleted(event.action.error)
) {
void signOut();
void endSession();
}
});
}
31 changes: 31 additions & 0 deletions apps/mobile/src/auth/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,35 @@ describe('session', () => {
expect(error).toBeInstanceOf(ApiRequestError);
expect(error).toMatchObject({ kind: 'network' });
});

it('ends a deleted session at once and once, even when Firebase fails to sign out', async () => {
const { provider, setUser } = fakeProvider();
const failure = new Error('auth/internal-error');
vi.mocked(provider.signOut).mockRejectedValue(failure);
const onSignedInChange = vi.fn();
const onSignOutFailed = vi.fn();
const session = createSession(provider, { onSignedInChange, onSignOutFailed });
setUser(null);
setUser(camille);
await Promise.all([session.endSession(), session.endSession()]);
expect(session.store.getState()).toEqual({ status: 'anonymous', user: null });
expect(await session.getToken({ forceRefresh: false })).toBeNull();
expect(provider.signOut).toHaveBeenCalledTimes(1);
expect(onSignOutFailed).toHaveBeenCalledWith(failure);
expect(onSignedInChange.mock.calls).toEqual([[true], [false]]);
await session.endSession();
expect(provider.signOut).toHaveBeenCalledTimes(1);
});

it('takes a new sign-in after the end of the session, Firebase silent or not', () => {
const { provider, setUser } = fakeProvider();
const onSignedInChange = vi.fn();
const session = createSession(provider, { onSignedInChange });
setUser(camille);
void session.endSession();
session.signedIn(camille);
setUser(camille);
expect(session.store.getState()).toEqual({ status: 'signedIn', user: camille });
expect(onSignedInChange.mock.calls).toEqual([[false], [true]]);
});
});
29 changes: 27 additions & 2 deletions apps/mobile/src/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface SessionOptions {
* the caller's rights, so the answers kept for the previous caller go (D-075).
*/
onSignedInChange: (isSignedIn: boolean) => void;
/** Firebase could not sign out after `endSession`: the app is anonymous all the same. */
onSignOutFailed?: (error: unknown) => void;
}

/**
Expand All @@ -24,14 +26,16 @@ interface SessionOptions {
export function createSession(provider: AuthProvider, options: SessionOptions) {
const store = createStore<SessionState>(() => ({ status: 'loading', user: null }));

provider.onUserChanged((user) => {
function setUser(user: AuthUser | null) {
const wasSignedIn = store.getState().status === 'signedIn';
const wasLoading = store.getState().status === 'loading';
store.setState({ status: user ? 'signedIn' : 'anonymous', user });
if (!wasLoading && wasSignedIn !== (user !== null)) {
options.onSignedInChange(user !== null);
}
});
}

provider.onUserChanged(setUser);

/**
* For the API client: null when signed out. A token Firebase cannot give (offline, its refresh
Expand All @@ -53,6 +57,27 @@ export function createSession(provider: AuthProvider, options: SessionOptions) {
getToken,
isSignedIn: () => store.getState().status === 'signedIn',
signOut: () => provider.signOut(),
/**
* The account is gone (deleted from this phone, or a 401 « Account deleted »): the app is
* anonymous at once, its token no longer sent, whatever Firebase does; Firebase signs out
* next. Once per account: called again while anonymous, it does nothing.
*/
endSession: async () => {
if (store.getState().status !== 'signedIn') {
return;
}
setUser(null);
try {
await provider.signOut();
} catch (error) {
options.onSignOutFailed?.(error);
}
},
/**
* A sign-in that went through: the session takes its user at once, without waiting for
* Firebase to tell it, which it may not do for the account it still holds.
*/
signedIn: (user: AuthUser) => setUser(user),
};
}

Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/auth/signIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ function readPendingEmail(): PendingEmail | null {
export const signInFlow = createSignInFlow({
provider: authProvider,
isSignedIn: session.isSignedIn,
onSignedIn: session.signedIn,
endSession: session.endSession,
getFirstName: async () => (await api.getMe()).firstName,
saveFirstName: async (firstName) => {
await api.updateMe({ firstName });
Expand Down
19 changes: 15 additions & 4 deletions apps/mobile/src/profile/me.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { ApiRequestError } from '@widoo/api-client';
import type { AppConfig, Me, UpdateMe } from '@widoo/shared';
import { api } from '../api/client';
Expand Down Expand Up @@ -54,7 +55,16 @@ export function useMe() {
} else {
state = { status: 'loading' };
}
return { state, refetch: () => void query.refetch(), isFetching: query.isFetching };
// Stable, so that a screen may ask again on focus without asking again at each render; and
// without account, nothing: `refetch` would send the request even with the query disabled.
const { refetch: refetchQuery } = query;
const isSignedIn = uid !== null;
const refetch = useCallback(() => {
if (isSignedIn) {
void refetchQuery();
}
}, [isSignedIn, refetchQuery]);
return { state, refetch, isFetching: query.isFetching };
}

/** `/config`: the active anecdote questions, in the order of the sheet. */
Expand Down Expand Up @@ -83,12 +93,13 @@ export function useUpdateMe() {
}

/**
* Deletes the account (D-025): `DELETE /me`, then the sign-out, which brings the app back to its
* anonymous state; the profile kept on the phone goes with the session.
* Deletes the account (D-025): `DELETE /me`, then the end of the session, which brings the app
* back to its anonymous state at once, even if Firebase fails to sign out; the profile kept on
* the phone goes with the session.
*/
export async function deleteAccount(): Promise<void> {
await api.deleteMe();
await session.signOut();
await session.endSession();
}

/** Forgets every profile kept on the phone and in memory. */
Expand Down
Loading
Loading