Files
multica/apps/mobile/data/auth-store.ts
Naiyuan Qing f5dbcad533 feat(mobile): settings — profile + notifications subscreens, RNR primitives, API helpers
Settings page rewritten to use RNR primitives (RadioGroup, Switch,
Avatar, Separator) instead of self-drawn equivalents, removes 3
hardcoded #71717a hex colors in favor of THEME tokens, and adds
Alert.alert confirmation on sign-out with destructive Button variant.

Two new push subscreens under more/settings/:
  - profile.tsx     edits name + avatar. Avatar tap opens iOS native
                    ActionSheetIOS (Take Photo / Library / Remove) via
                    expo-image-picker, then PATCH /api/me.
  - notifications.tsx  5 inbox groups + system_notifications toggle,
                       backed by optimistic PUT /api/notification-preferences.

New mobile-owned query + mutation for notification preferences mirror
the web design (no runtime import — per CLAUDE.md "Mobile-owned
updaters"). auth-store gets setUser action for in-memory user update
after profile PATCH.

ApiClient gains fetchValidated + fetchValidatedWith private helpers
that collapse the fetch+parseWithFallback envelope. 4 settings-related
methods migrated as canary (getMe, updateMe, getNotificationPreferences,
updateNotificationPreferences); remaining 30+ read methods migrate
progressively in later PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:34:43 +08:00

80 lines
2.5 KiB
TypeScript

/**
* Mobile auth store — Zustand. Logic mirrors packages/core/auth/store.ts:
* - Token written ONLY on successful verifyCode
* - 401 → clear token; non-401 (5xx / network blip) → preserve token so
* the next launch can retry
* - logout = clear token + clear in-memory user + setToken(null)
*
* NOT shared with web/desktop (per Sharing Principles in root CLAUDE.md).
* Storage backend is expo-secure-store (mobile only); web uses HttpOnly
* cookies, desktop uses localStorage via StorageAdapter.
*/
import { create } from "zustand";
import type { User } from "@multica/core/types";
import { api, ApiError } from "./api";
import { clearToken, getToken, setToken } from "./secure-storage";
import { useWorkspaceStore } from "./workspace-store";
interface AuthState {
user: User | null;
isLoading: boolean;
initialize: () => Promise<void>;
sendCode: (email: string) => Promise<void>;
verifyCode: (email: string, code: string) => Promise<User>;
logout: () => Promise<void>;
/** Overwrite the in-memory user — call after PATCH /api/me so name/avatar
* edits land without a refetch. Server response is the source of truth. */
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
// Restore the persisted workspace slug alongside the auth token so the
// entry redirect (app/index.tsx) can route directly to the last-used
// workspace without flashing /select-workspace.
await useWorkspaceStore.getState().restoreSlug();
const token = await getToken();
if (!token) {
set({ isLoading: false });
return;
}
api.setToken(token);
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch (err) {
// Only clear token on a genuine 401. Network blips / 5xx keep the
// token so the next launch (or a manual refresh) can retry.
if (err instanceof ApiError && err.status === 401) {
await clearToken();
api.setToken(null);
}
set({ user: null, isLoading: false });
}
},
sendCode: async (email) => {
await api.sendCode(email);
},
verifyCode: async (email, code) => {
const { token, user } = await api.verifyCode(email, code);
await setToken(token);
api.setToken(token);
set({ user });
return user;
},
logout: async () => {
await clearToken();
api.setToken(null);
set({ user: null });
},
setUser: (user) => set({ user }),
}));