diff --git a/app.json b/app.json
index a369176..e8b2faf 100644
--- a/app.json
+++ b/app.json
@@ -10,7 +10,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app",
- "buildNumber": "18",
+ "buildNumber": "20",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
diff --git a/app/(auth)/index.tsx b/app/(auth)/index.tsx
index 395cf3e..fe31b03 100644
--- a/app/(auth)/index.tsx
+++ b/app/(auth)/index.tsx
@@ -1,5 +1,12 @@
import { Redirect } from "expo-router";
+import { useAccounts } from "@/contexts/AccountsContext";
export default function AuthIndex() {
+ const { accounts, activeAccountId } = useAccounts();
+
+ if (!activeAccountId && accounts.length > 0) {
+ return ;
+ }
+
return ;
}
diff --git a/app/(auth)/select-account.tsx b/app/(auth)/select-account.tsx
new file mode 100644
index 0000000..9cf8e51
--- /dev/null
+++ b/app/(auth)/select-account.tsx
@@ -0,0 +1,159 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Redirect, router } from "expo-router";
+import { useState } from "react";
+import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
+
+import { AuthCard } from "@/components/auth/AuthCard";
+import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
+import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
+import { Button } from "@/components/ui/Button";
+import { fonts, radii, spacing } from "@/constants/theme";
+import { useAccounts } from "@/contexts/AccountsContext";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import { startAdditionalAccountSignIn } from "@/lib/add-account";
+import { formatServerHost } from "@/lib/server-mode";
+
+function initials(name: string, email: string) {
+ const source = name.trim() || email.trim();
+ const parts = source.split(/\s+/).filter(Boolean);
+ if (parts.length >= 2) {
+ return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
+ }
+ return (source[0] ?? "?").toUpperCase();
+}
+
+export default function SelectAccountScreen() {
+ const { colors } = useAppTheme();
+ const { accounts, activeAccountId, switchAccount, clearActiveAccount } = useAccounts();
+ const [selectingId, setSelectingId] = useState(null);
+ const [adding, setAdding] = useState(false);
+
+ async function handleSelect(accountId: string) {
+ if (selectingId) return;
+ setSelectingId(accountId);
+ try {
+ await switchAccount(accountId);
+ router.replace("/(auth)/sign-in");
+ } finally {
+ setSelectingId(null);
+ }
+ }
+
+ async function handleAddAccount() {
+ if (adding) return;
+ setAdding(true);
+ try {
+ await startAdditionalAccountSignIn(clearActiveAccount);
+ } finally {
+ setAdding(false);
+ }
+ }
+
+ if (activeAccountId || accounts.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+ {accounts.map((account) => {
+ const isSelecting = selectingId === account.id;
+
+ return (
+ void handleSelect(account.id)}
+ style={({ pressed }) => [
+ styles.accountRow,
+ { backgroundColor: colors.muted, borderColor: colors.border },
+ pressed && styles.pressed,
+ ]}
+ >
+
+
+ {initials(account.name, account.email)}
+
+
+
+
+ {account.name || account.email}
+
+
+ {account.email}
+
+
+ {formatServerHost(account.instanceUrl)}
+
+
+ {isSelecting ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ list: {
+ gap: spacing.sm,
+ },
+ accountRow: {
+ minHeight: 72,
+ borderRadius: radii.lg,
+ borderWidth: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.md,
+ padding: spacing.md,
+ },
+ avatar: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ avatarText: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 13,
+ },
+ accountMeta: {
+ flex: 1,
+ gap: 2,
+ },
+ accountName: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 15,
+ lineHeight: 20,
+ },
+ accountSub: {
+ fontFamily: fonts.body,
+ fontSize: 12,
+ lineHeight: 16,
+ },
+ pressed: {
+ opacity: 0.9,
+ },
+});
diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx
index cbfd412..f2f3c1d 100644
--- a/app/(auth)/sign-in.tsx
+++ b/app/(auth)/sign-in.tsx
@@ -18,13 +18,14 @@ import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
import { signInWithAuthentik } from "@/lib/auth-oauth";
+import { prepareAuthScreenSession } from "@/lib/auth-session";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { formatAuthErrorMessage } from "@/lib/trpc-errors";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() {
const authClient = useAuthClient();
- const { apiUrl, activeAccountId, registerAccount } = useAccounts();
+ const { apiUrl, activeAccountId, clearActiveAccount, registerAccount } = useAccounts();
const { colors } = useAppTheme();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -35,6 +36,10 @@ export default function SignInScreen() {
const [signupsDisabled, setSignupsDisabled] = useState(false);
const { touch, visible, markSubmitted } = useFieldVisibility();
+ useEffect(() => {
+ void prepareAuthScreenSession(authClient, activeAccountId, clearActiveAccount);
+ }, [authClient, activeAccountId, clearActiveAccount]);
+
useEffect(() => {
let cancelled = false;
diff --git a/app/_layout.tsx b/app/_layout.tsx
index b94e71e..369217f 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -103,12 +103,13 @@ export default function RootLayout() {
function RootNavigator() {
const { data: session, isPending } = useSession();
+ const { activeAccountId } = useAccounts();
if (isPending) {
return ;
}
- const isAuthenticated = Boolean(session?.user);
+ const isAuthenticated = Boolean(session?.user && activeAccountId);
return (
;
+ return (
+ typeof account.id === "string" &&
+ account.id.length > 0 &&
+ typeof account.instanceUrl === "string" &&
+ account.instanceUrl.length > 0 &&
+ typeof account.userId === "string" &&
+ account.userId.length > 0 &&
+ typeof account.email === "string" &&
+ typeof account.name === "string" &&
+ typeof account.lastUsedAt === "number"
+ );
+}
+
export function buildAccountId(instanceUrl: string, userId: string) {
const host = instanceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
return `${host}::${userId}`;
@@ -26,8 +43,8 @@ export async function loadAccounts(): Promise {
const raw = await AsyncStorage.getItem(ACCOUNTS_KEY);
if (!raw) return [];
try {
- const parsed = JSON.parse(raw) as SavedAccount[];
- return Array.isArray(parsed) ? parsed : [];
+ const parsed = JSON.parse(raw) as unknown;
+ return Array.isArray(parsed) ? parsed.filter(isSavedAccount) : [];
} catch {
return [];
}
diff --git a/lib/auth-cookie.ts b/lib/auth-cookie.ts
index ffb603e..06ebc6d 100644
--- a/lib/auth-cookie.ts
+++ b/lib/auth-cookie.ts
@@ -2,6 +2,7 @@ import { getCookie as serializeStoredCookies } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
import type { createAuthClient } from "better-auth/react";
+import { GUEST_AUTH_STORAGE_PREFIX } from "@/lib/auth-storage";
import { normalizeSecureStoreKey } from "@/lib/secure-store-keys";
type AuthClient = ReturnType;
@@ -9,6 +10,7 @@ type AuthClient = ReturnType;
const CHUNK_MARKER = "\u0001ba-chunks:";
const SESSION_TOKEN_COOKIE_PART =
/(?:^|;\s*)(?:__Secure-)?[^=]*session_token=([^;]+)/;
+const AUTH_COOKIE_DEBUG = process.env.EXPO_PUBLIC_AUTH_COOKIE_DEBUG === "1";
function readSecureStoreValueSync(key: string): string | null {
const value = SecureStore.getItem(key);
@@ -27,16 +29,7 @@ function readSecureStoreValueSync(key: string): string | null {
return assembled;
}
-/** Read session cookie string for tRPC requests (Expo client plugin + SecureStore fallback). */
-export function getAuthCookie(
- authClient: AuthClient,
- storagePrefix: string,
-): string | null {
- const fromClient = (
- authClient as AuthClient & { getCookie?: () => string }
- ).getCookie?.();
- if (fromClient?.trim()) return fromClient.trim();
-
+function readStoredCookie(storagePrefix: string): string | null {
const raw = readSecureStoreValueSync(
normalizeSecureStoreKey(`${storagePrefix}_cookie`),
);
@@ -46,14 +39,85 @@ export function getAuthCookie(
return cookie.trim() || null;
}
+function cookieNames(cookie: string): string[] {
+ return cookie
+ .split(";")
+ .map((part) => part.trim().split("=", 1)[0])
+ .filter(Boolean);
+}
+
+/** Read session cookie string for tRPC requests (Expo client plugin + SecureStore fallback). */
+export function getAuthCookie(
+ authClient: AuthClient,
+ storagePrefix: string,
+): string | null {
+ const fromClient = (
+ authClient as AuthClient & { getCookie?: () => string }
+ ).getCookie?.();
+ if (fromClient?.trim()) {
+ const cookie = fromClient.trim();
+ if (AUTH_COOKIE_DEBUG) {
+ console.info("[auth-cookie] using client cookie", {
+ storagePrefix,
+ length: cookie.length,
+ names: cookieNames(cookie),
+ });
+ }
+ return cookie;
+ }
+
+ const fromPrefix = readStoredCookie(storagePrefix);
+ if (fromPrefix) {
+ if (AUTH_COOKIE_DEBUG) {
+ console.info("[auth-cookie] using stored cookie", {
+ storagePrefix,
+ length: fromPrefix.length,
+ names: cookieNames(fromPrefix),
+ });
+ }
+ return fromPrefix;
+ }
+
+ const fromGuest =
+ storagePrefix === GUEST_AUTH_STORAGE_PREFIX
+ ? null
+ : readStoredCookie(GUEST_AUTH_STORAGE_PREFIX);
+ if (AUTH_COOKIE_DEBUG) {
+ console.info("[auth-cookie] resolved tRPC cookie", {
+ storagePrefix,
+ fallbackPrefix:
+ fromGuest && storagePrefix !== GUEST_AUTH_STORAGE_PREFIX
+ ? GUEST_AUTH_STORAGE_PREFIX
+ : null,
+ hasCookie: Boolean(fromGuest),
+ length: fromGuest?.length ?? 0,
+ names: fromGuest ? cookieNames(fromGuest) : [],
+ });
+ }
+ return fromGuest;
+}
+
export function getAuthCookieHeaders(
authClient: AuthClient,
storagePrefix: string,
): Record {
const cookie = getAuthCookie(authClient, storagePrefix);
- if (!cookie) return {};
+ if (!cookie) {
+ if (AUTH_COOKIE_DEBUG) {
+ console.info("[auth-cookie] no tRPC auth cookie", { storagePrefix });
+ }
+ return {};
+ }
const sessionToken = cookie.match(SESSION_TOKEN_COOKIE_PART)?.[1];
+ if (AUTH_COOKIE_DEBUG) {
+ console.info("[auth-cookie] sending tRPC auth headers", {
+ storagePrefix,
+ cookieLength: cookie.length,
+ cookieNames: cookieNames(cookie),
+ hasSessionTokenHeader: Boolean(sessionToken),
+ });
+ }
return {
cookie,
Cookie: cookie,
diff --git a/lib/auth-storage.ts b/lib/auth-storage.ts
index 2c08552..c5ca839 100644
--- a/lib/auth-storage.ts
+++ b/lib/auth-storage.ts
@@ -66,8 +66,13 @@ export async function readStoredSessionUser(prefix: string): Promise<{
}
}
-export async function migrateAuthStorage(fromPrefix: string, toPrefix: string): Promise {
+export async function migrateAuthStorage(
+ fromPrefix: string,
+ toPrefix: string,
+ options: { clearSource?: boolean } = {},
+): Promise {
if (fromPrefix === toPrefix) return;
+ const clearSource = options.clearSource ?? true;
await Promise.all(
AUTH_STORAGE_SUFFIXES.map((suffix) =>
@@ -75,7 +80,9 @@ export async function migrateAuthStorage(fromPrefix: string, toPrefix: string):
),
);
- await clearAuthStorage(fromPrefix);
+ if (clearSource) {
+ await clearAuthStorage(fromPrefix);
+ }
}
export async function clearAuthStorage(prefix: string): Promise {
@@ -124,7 +131,11 @@ export async function finalizeAuthenticatedAccount(input: {
? authStoragePrefix(input.activeAccountId)
: GUEST_AUTH_STORAGE_PREFIX;
- await migrateAuthStorage(sourcePrefix, targetPrefix);
+ if (sourcePrefix !== targetPrefix) {
+ await clearAuthStorage(targetPrefix);
+ }
+
+ await migrateAuthStorage(sourcePrefix, targetPrefix, { clearSource: false });
await input.registerAccount({
instanceUrl: input.apiUrl,