@oxyhq/services 12.2.0 → 13.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/lib/commonjs/index.js +56 -6
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/AccountMenuButton.js +26 -9
  4. package/lib/commonjs/ui/components/AccountMenuButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/AccountSwitcher.js +792 -0
  6. package/lib/commonjs/ui/components/AccountSwitcher.js.map +1 -0
  7. package/lib/commonjs/ui/components/ActiveAccountBanner.js +144 -0
  8. package/lib/commonjs/ui/components/ActiveAccountBanner.js.map +1 -0
  9. package/lib/commonjs/ui/context/OxyContext.js +72 -28
  10. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  11. package/lib/commonjs/ui/index.js +42 -0
  12. package/lib/commonjs/ui/index.js.map +1 -1
  13. package/lib/commonjs/ui/navigation/routes.js +4 -1
  14. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  15. package/lib/commonjs/ui/screens/AccountMembersScreen.js +469 -0
  16. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -0
  17. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +264 -0
  18. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -0
  19. package/lib/commonjs/ui/screens/AccountSwitcherScreen.js +51 -0
  20. package/lib/commonjs/ui/screens/AccountSwitcherScreen.js.map +1 -0
  21. package/lib/commonjs/ui/screens/{CreateManagedAccountScreen.js → CreateAccountScreen.js} +116 -25
  22. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -0
  23. package/lib/commonjs/ui/screens/ManageAccountScreen.js +11 -11
  24. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  25. package/lib/module/index.js +16 -4
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ui/components/AccountMenuButton.js +26 -9
  28. package/lib/module/ui/components/AccountMenuButton.js.map +1 -1
  29. package/lib/module/ui/components/AccountSwitcher.js +786 -0
  30. package/lib/module/ui/components/AccountSwitcher.js.map +1 -0
  31. package/lib/module/ui/components/ActiveAccountBanner.js +141 -0
  32. package/lib/module/ui/components/ActiveAccountBanner.js.map +1 -0
  33. package/lib/module/ui/context/OxyContext.js +72 -28
  34. package/lib/module/ui/context/OxyContext.js.map +1 -1
  35. package/lib/module/ui/index.js +5 -0
  36. package/lib/module/ui/index.js.map +1 -1
  37. package/lib/module/ui/navigation/routes.js +4 -1
  38. package/lib/module/ui/navigation/routes.js.map +1 -1
  39. package/lib/module/ui/screens/AccountMembersScreen.js +464 -0
  40. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -0
  41. package/lib/module/ui/screens/AccountSettingsScreen.js +259 -0
  42. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -0
  43. package/lib/module/ui/screens/AccountSwitcherScreen.js +48 -0
  44. package/lib/module/ui/screens/AccountSwitcherScreen.js.map +1 -0
  45. package/lib/module/ui/screens/{CreateManagedAccountScreen.js → CreateAccountScreen.js} +117 -26
  46. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -0
  47. package/lib/module/ui/screens/ManageAccountScreen.js +11 -11
  48. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  49. package/lib/typescript/commonjs/index.d.ts +7 -1
  50. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/components/AccountMenuButton.d.ts +13 -4
  52. package/lib/typescript/commonjs/ui/components/AccountMenuButton.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts +47 -0
  54. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/ui/components/{ActingAsBanner.d.ts → ActiveAccountBanner.d.ts} +1 -1
  56. package/lib/typescript/commonjs/ui/components/ActiveAccountBanner.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +33 -5
  58. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/ui/index.d.ts +5 -0
  61. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
  63. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts +10 -0
  65. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts +12 -0
  67. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -0
  68. package/lib/typescript/commonjs/ui/screens/AccountSwitcherScreen.d.ts +11 -0
  69. package/lib/typescript/commonjs/ui/screens/AccountSwitcherScreen.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts +11 -0
  71. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -0
  72. package/lib/typescript/module/index.d.ts +7 -1
  73. package/lib/typescript/module/index.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/components/AccountMenuButton.d.ts +13 -4
  75. package/lib/typescript/module/ui/components/AccountMenuButton.d.ts.map +1 -1
  76. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts +47 -0
  77. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts.map +1 -0
  78. package/lib/typescript/module/ui/components/{ActingAsBanner.d.ts → ActiveAccountBanner.d.ts} +1 -1
  79. package/lib/typescript/module/ui/components/ActiveAccountBanner.d.ts.map +1 -0
  80. package/lib/typescript/module/ui/context/OxyContext.d.ts +33 -5
  81. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  82. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/index.d.ts +5 -0
  84. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
  86. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts +10 -0
  88. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -0
  89. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts +12 -0
  90. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -0
  91. package/lib/typescript/module/ui/screens/AccountSwitcherScreen.d.ts +11 -0
  92. package/lib/typescript/module/ui/screens/AccountSwitcherScreen.d.ts.map +1 -0
  93. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts +11 -0
  94. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -0
  95. package/package.json +3 -3
  96. package/src/index.ts +18 -4
  97. package/src/ui/components/AccountMenuButton.tsx +25 -10
  98. package/src/ui/components/AccountSwitcher.tsx +763 -0
  99. package/src/ui/components/ActiveAccountBanner.tsx +130 -0
  100. package/src/ui/context/OxyContext.tsx +122 -37
  101. package/src/ui/index.ts +5 -0
  102. package/src/ui/navigation/routes.ts +8 -2
  103. package/src/ui/screens/AccountMembersScreen.tsx +481 -0
  104. package/src/ui/screens/AccountSettingsScreen.tsx +261 -0
  105. package/src/ui/screens/AccountSwitcherScreen.tsx +43 -0
  106. package/src/ui/screens/{CreateManagedAccountScreen.tsx → CreateAccountScreen.tsx} +140 -22
  107. package/src/ui/screens/ManageAccountScreen.tsx +19 -19
  108. package/lib/commonjs/ui/components/ActingAsBanner.js +0 -140
  109. package/lib/commonjs/ui/components/ActingAsBanner.js.map +0 -1
  110. package/lib/commonjs/ui/screens/CreateManagedAccountScreen.js.map +0 -1
  111. package/lib/module/ui/components/ActingAsBanner.js +0 -137
  112. package/lib/module/ui/components/ActingAsBanner.js.map +0 -1
  113. package/lib/module/ui/screens/CreateManagedAccountScreen.js.map +0 -1
  114. package/lib/typescript/commonjs/ui/components/ActingAsBanner.d.ts.map +0 -1
  115. package/lib/typescript/commonjs/ui/screens/CreateManagedAccountScreen.d.ts +0 -5
  116. package/lib/typescript/commonjs/ui/screens/CreateManagedAccountScreen.d.ts.map +0 -1
  117. package/lib/typescript/module/ui/components/ActingAsBanner.d.ts.map +0 -1
  118. package/lib/typescript/module/ui/screens/CreateManagedAccountScreen.d.ts +0 -5
  119. package/lib/typescript/module/ui/screens/CreateManagedAccountScreen.d.ts.map +0 -1
  120. package/src/ui/components/ActingAsBanner.tsx +0 -129
@@ -0,0 +1,763 @@
1
+ import type React from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ View,
5
+ Text,
6
+ TextInput,
7
+ TouchableOpacity,
8
+ StyleSheet,
9
+ ScrollView,
10
+ Modal,
11
+ Pressable,
12
+ Platform,
13
+ ActivityIndicator,
14
+ type ViewStyle,
15
+ } from 'react-native';
16
+ import { Ionicons } from '@expo/vector-icons';
17
+ import { toast, useDialogControl, Dialog } from '@oxyhq/bloom';
18
+ import { Divider } from '@oxyhq/bloom/divider';
19
+ import { useTheme } from '@oxyhq/bloom/theme';
20
+ import { getAccountDisplayName, isDev, logger as loggerUtil } from '@oxyhq/core';
21
+ import type { AccountNode } from '@oxyhq/core';
22
+ import Avatar from './Avatar';
23
+ import { useOxy } from '../context/OxyContext';
24
+ import { useI18n } from '../hooks/useI18n';
25
+ import { buildAccountRows, type AccountRow } from './accountMenuRows';
26
+ import { useDeviceAccounts } from '../hooks/useDeviceAccounts';
27
+ import type { AccountMenuAnchor } from './AccountMenu';
28
+
29
+ const isWeb = Platform.OS === 'web';
30
+ const PANEL_WIDTH = 380;
31
+
32
+ /**
33
+ * The set of action callbacks the switcher body needs. Supplied by whichever
34
+ * chrome wraps it — the {@link AccountSwitcher} modal/popover (web header chip,
35
+ * native sheet) or the {@link AccountSwitcherScreen} bottom-sheet route.
36
+ */
37
+ export interface AccountSwitcherActions {
38
+ /** Close the surface (dismiss the modal, or pop the bottom sheet). */
39
+ onClose: () => void;
40
+ /** Start adding another device sign-in. */
41
+ onAddAccount: () => void;
42
+ /** Open "Manage your Oxy Account" (the caller's own personal account). */
43
+ onNavigateManage: () => void;
44
+ /** Open the create-account flow. */
45
+ onCreateAccount: () => void;
46
+ /** Open the per-account settings surface for a non-personal account. */
47
+ onOpenAccountSettings?: (accountId: string) => void;
48
+ /** Called before the active device session changes so apps can clear tenant state. */
49
+ onBeforeSessionChange?: () => void | Promise<void>;
50
+ }
51
+
52
+ interface TreeEntry {
53
+ root: AccountNode;
54
+ children: AccountNode[];
55
+ }
56
+
57
+ /**
58
+ * Group flat account nodes into a 2-level tree (org → direct children). A node
59
+ * whose parent is not in the set is treated as a root; any deeper node not
60
+ * captured under a root is promoted to its own root row so nothing is hidden.
61
+ */
62
+ function toTree(nodes: AccountNode[]): TreeEntry[] {
63
+ const byId = new Map(nodes.map((node) => [node.accountId, node]));
64
+ const childrenOf = new Map<string, AccountNode[]>();
65
+ for (const node of nodes) {
66
+ if (node.parentAccountId && byId.has(node.parentAccountId)) {
67
+ const arr = childrenOf.get(node.parentAccountId) ?? [];
68
+ arr.push(node);
69
+ childrenOf.set(node.parentAccountId, arr);
70
+ }
71
+ }
72
+ const rendered = new Set<string>();
73
+ const result: TreeEntry[] = [];
74
+ for (const node of nodes) {
75
+ const isRoot = !node.parentAccountId || !byId.has(node.parentAccountId);
76
+ if (!isRoot) continue;
77
+ const children = childrenOf.get(node.accountId) ?? [];
78
+ result.push({ root: node, children });
79
+ rendered.add(node.accountId);
80
+ for (const child of children) rendered.add(child.accountId);
81
+ }
82
+ for (const node of nodes) {
83
+ if (!rendered.has(node.accountId)) {
84
+ result.push({ root: node, children: [] });
85
+ rendered.add(node.accountId);
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * The presentational, chrome-agnostic body of the unified account switcher.
93
+ *
94
+ * Two levels, relationship-aware:
95
+ * - Section A — accounts signed in on THIS device (independent sign-ins). Tap
96
+ * to switch session; per-row sign-out; "Add another account".
97
+ * - Section B — the account GRAPH under the active sign-in: "Your accounts"
98
+ * (self / owned) and "Shared with you" (member). 2-level tree (org →
99
+ * children), role badge, search, one-tap to act-as.
100
+ *
101
+ * Reads everything from `useOxy()` / `useDeviceAccounts()`.
102
+ */
103
+ export const AccountSwitcherView: React.FC<AccountSwitcherActions> = ({
104
+ onClose,
105
+ onAddAccount,
106
+ onNavigateManage,
107
+ onCreateAccount,
108
+ onOpenAccountSettings,
109
+ onBeforeSessionChange,
110
+ }) => {
111
+ const {
112
+ activeSessionId,
113
+ switchSession,
114
+ removeSession,
115
+ logout,
116
+ logoutAll,
117
+ accounts,
118
+ actingAs,
119
+ setActingAs,
120
+ oxyServices,
121
+ } = useOxy();
122
+ const { t, locale } = useI18n();
123
+ const { colors } = useTheme();
124
+
125
+ const { accounts: deviceAccounts } = useDeviceAccounts();
126
+
127
+ const [busySessionId, setBusySessionId] = useState<string | null>(null);
128
+ const [removingSessionId, setRemovingSessionId] = useState<string | null>(null);
129
+ const [signingOut, setSigningOut] = useState(false);
130
+ const [signingOutAll, setSigningOutAll] = useState(false);
131
+ const [query, setQuery] = useState('');
132
+
133
+ const signOutDialog = useDialogControl();
134
+ const signOutAllDialog = useDialogControl();
135
+
136
+ const deviceRows = useMemo<AccountRow[]>(() => {
137
+ const built = buildAccountRows({ accounts: deviceAccounts });
138
+ const current = built.filter((row) => row.isActive);
139
+ const others = built.filter((row) => !row.isActive);
140
+ return [...current, ...others];
141
+ }, [deviceAccounts]);
142
+
143
+ const isSwitching = busySessionId !== null;
144
+ const actionDisabled = isSwitching || signingOut || signingOutAll;
145
+
146
+ // --- Account graph (Section B) ---
147
+ const filtered = useMemo<AccountNode[]>(() => {
148
+ const q = query.trim().toLowerCase();
149
+ if (!q) return accounts;
150
+ return accounts.filter((node) => {
151
+ const name = getAccountDisplayName(node.account, locale).toLowerCase();
152
+ const username = (node.account?.username ?? '').toLowerCase();
153
+ return name.includes(q) || username.includes(q);
154
+ });
155
+ }, [accounts, query, locale]);
156
+
157
+ const yourAccounts = useMemo(
158
+ () => toTree(filtered.filter((node) => node.relationship !== 'member')),
159
+ [filtered],
160
+ );
161
+ const sharedAccounts = useMemo(
162
+ () => toTree(filtered.filter((node) => node.relationship === 'member')),
163
+ [filtered],
164
+ );
165
+
166
+ const handleSwitchDevice = useCallback(async (sessionId: string) => {
167
+ if (busySessionId) return;
168
+ // Tapping the already-active sign-in returns to that sign-in's own
169
+ // personal account (clearing any account-graph switch). When already on
170
+ // the personal account this just closes.
171
+ if (sessionId === activeSessionId) {
172
+ if (actingAs !== null) setActingAs(null);
173
+ onClose();
174
+ return;
175
+ }
176
+ setBusySessionId(sessionId);
177
+ try {
178
+ await onBeforeSessionChange?.();
179
+ await switchSession(sessionId);
180
+ // The active sign-in changed; any account-graph switch belonged to the
181
+ // previous sign-in, so the new sign-in starts on its personal account.
182
+ if (actingAs !== null) setActingAs(null);
183
+ toast.success(t('accountSwitcher.toasts.switchSuccess') || 'Switched account');
184
+ onClose();
185
+ } catch (error) {
186
+ if (!isDev()) {
187
+ loggerUtil.warn('Switch account failed', { component: 'AccountSwitcher' }, error as unknown);
188
+ }
189
+ toast.error(t('accountSwitcher.toasts.switchFailed') || 'Failed to switch account');
190
+ } finally {
191
+ setBusySessionId(null);
192
+ }
193
+ }, [activeSessionId, busySessionId, actingAs, setActingAs, switchSession, t, onClose, onBeforeSessionChange]);
194
+
195
+ const handleRemoveDevice = useCallback(async (sessionId: string) => {
196
+ if (sessionId === activeSessionId || removingSessionId) return;
197
+ setRemovingSessionId(sessionId);
198
+ try {
199
+ await removeSession(sessionId);
200
+ toast.success(t('common.actions.signedOut') || 'Signed out');
201
+ } catch (error) {
202
+ loggerUtil.warn('Remove account failed', { component: 'AccountSwitcher' }, error as unknown);
203
+ toast.error(t('common.errors.signOutFailed') || 'Failed to sign out');
204
+ } finally {
205
+ setRemovingSessionId(null);
206
+ }
207
+ }, [activeSessionId, removingSessionId, removeSession, t]);
208
+
209
+ const performSignOut = useCallback(async () => {
210
+ if (signingOut) return;
211
+ setSigningOut(true);
212
+ try {
213
+ await onBeforeSessionChange?.();
214
+ await logout();
215
+ toast.success(t('common.actions.signedOut') || 'Signed out');
216
+ onClose();
217
+ } catch (error) {
218
+ loggerUtil.warn('Sign out failed', { component: 'AccountSwitcher' }, error as unknown);
219
+ toast.error(t('common.errors.signOutFailed') || 'Failed to sign out');
220
+ } finally {
221
+ setSigningOut(false);
222
+ }
223
+ }, [signingOut, logout, t, onClose, onBeforeSessionChange]);
224
+
225
+ const performSignOutAll = useCallback(async () => {
226
+ if (signingOutAll) return;
227
+ setSigningOutAll(true);
228
+ try {
229
+ await onBeforeSessionChange?.();
230
+ await logoutAll();
231
+ toast.success(t('accountSwitcher.toasts.signOutAllSuccess') || 'Signed out of all accounts');
232
+ onClose();
233
+ } catch (error) {
234
+ loggerUtil.warn('Sign out all failed', { component: 'AccountSwitcher' }, error as unknown);
235
+ toast.error(t('common.errors.signOutAllFailed') || 'Failed to sign out of all accounts');
236
+ } finally {
237
+ setSigningOutAll(false);
238
+ }
239
+ }, [signingOutAll, logoutAll, t, onClose, onBeforeSessionChange]);
240
+
241
+ const handleSelectAccount = useCallback((node: AccountNode) => {
242
+ // The personal (self) account = no delegation (acting-as null).
243
+ setActingAs(node.relationship === 'self' ? null : node.accountId);
244
+ onClose();
245
+ }, [setActingAs, onClose]);
246
+
247
+ const renderAccountNode = useCallback((node: AccountNode, isChild: boolean) => {
248
+ const displayName = getAccountDisplayName(node.account, locale);
249
+ const username = node.account?.username ? `@${node.account.username}` : null;
250
+ const active = node.relationship === 'self'
251
+ ? actingAs === null
252
+ : actingAs === node.accountId;
253
+ const role = node.callerMembership?.role;
254
+ const avatarUri = node.account?.avatar
255
+ ? oxyServices.getFileDownloadUrl(node.account.avatar, 'thumb')
256
+ : undefined;
257
+ const permissions = node.callerMembership?.permissions ?? [];
258
+ const canManage = node.relationship !== 'member'
259
+ || permissions.includes('account:update')
260
+ || permissions.includes('members:read');
261
+ const showSettings = node.relationship !== 'self' && !!onOpenAccountSettings && canManage;
262
+
263
+ return (
264
+ <TouchableOpacity
265
+ key={`node-${node.accountId}`}
266
+ accessibilityRole="menuitem"
267
+ accessibilityLabel={displayName}
268
+ accessibilityState={{ selected: active }}
269
+ onPress={() => handleSelectAccount(node)}
270
+ activeOpacity={0.6}
271
+ style={[
272
+ styles.accountRow,
273
+ isChild && styles.childRow,
274
+ active && { backgroundColor: colors.primarySubtle },
275
+ ]}
276
+ >
277
+ <Avatar uri={avatarUri} name={displayName} size={isChild ? 28 : 34} />
278
+ <View style={styles.accountInfo}>
279
+ <View style={styles.nameRow}>
280
+ <Text style={[styles.accountName, { color: colors.text }]} numberOfLines={1}>
281
+ {displayName}
282
+ </Text>
283
+ {role ? (
284
+ <View style={[styles.roleBadge, { backgroundColor: colors.card }]}>
285
+ <Text style={[styles.roleBadgeText, { color: colors.textSecondary }]}>
286
+ {t(`accounts.roles.${role}.label`) || role}
287
+ </Text>
288
+ </View>
289
+ ) : null}
290
+ </View>
291
+ {username ? (
292
+ <Text style={[styles.accountEmail, { color: colors.textSecondary }]} numberOfLines={1}>
293
+ {username}
294
+ </Text>
295
+ ) : null}
296
+ </View>
297
+ {showSettings ? (
298
+ <TouchableOpacity
299
+ accessibilityRole="button"
300
+ accessibilityLabel={t('accounts.settings.title') || 'Account settings'}
301
+ onPress={() => onOpenAccountSettings?.(node.accountId)}
302
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
303
+ style={styles.settingsButton}
304
+ >
305
+ <Ionicons name="settings-outline" size={16} color={colors.textSecondary} />
306
+ </TouchableOpacity>
307
+ ) : null}
308
+ {active ? <Ionicons name="checkmark" size={20} color={colors.primary} /> : null}
309
+ </TouchableOpacity>
310
+ );
311
+ }, [actingAs, colors, handleSelectAccount, locale, onOpenAccountSettings, oxyServices, t]);
312
+
313
+ const renderTree = useCallback((entries: TreeEntry[]) => (
314
+ entries.map((entry) => (
315
+ <View key={`tree-${entry.root.accountId}`}>
316
+ {renderAccountNode(entry.root, false)}
317
+ {entry.children.map((child) => renderAccountNode(child, true))}
318
+ </View>
319
+ ))
320
+ ), [renderAccountNode]);
321
+
322
+ return (
323
+ <ScrollView
324
+ style={styles.scroll}
325
+ contentContainerStyle={styles.scrollContent}
326
+ showsVerticalScrollIndicator={false}
327
+ keyboardShouldPersistTaps="handled"
328
+ >
329
+ {/* Section A — device sign-ins */}
330
+ <Text style={[styles.sectionLabel, { color: colors.textSecondary }]}>
331
+ {t('accountSwitcher.sections.thisDevice') || 'On this device'}
332
+ </Text>
333
+ {deviceRows.map((row) => {
334
+ const isBusy = busySessionId === row.sessionId;
335
+ const isRemoving = removingSessionId === row.sessionId;
336
+ // The active sign-in is THE current account only when no
337
+ // account-graph switch is in effect; otherwise a switched-into
338
+ // account is current and this row reads as the signed-in (but not
339
+ // active) account — tappable to return to it.
340
+ const isCurrentAccount = row.isActive && actingAs === null;
341
+ return (
342
+ <TouchableOpacity
343
+ key={`device-${row.sessionId}`}
344
+ accessibilityRole="menuitem"
345
+ accessibilityLabel={row.displayName}
346
+ accessibilityState={{ selected: isCurrentAccount }}
347
+ onPress={() => handleSwitchDevice(row.sessionId)}
348
+ disabled={isCurrentAccount || isBusy || isSwitching}
349
+ activeOpacity={0.6}
350
+ style={[
351
+ styles.accountRow,
352
+ isCurrentAccount && { backgroundColor: colors.primarySubtle },
353
+ isSwitching && !row.isActive && styles.rowDisabled,
354
+ ]}
355
+ >
356
+ <Avatar uri={row.avatarUri} name={row.displayName} size={row.isActive ? 40 : 32} />
357
+ <View style={styles.accountInfo}>
358
+ <Text
359
+ style={[styles.accountName, { color: colors.text }, row.isActive && styles.accountNameActive]}
360
+ numberOfLines={1}
361
+ >
362
+ {row.displayName}
363
+ </Text>
364
+ {row.secondary ? (
365
+ <Text style={[styles.accountEmail, { color: colors.textSecondary }]} numberOfLines={1}>
366
+ {row.secondary}
367
+ </Text>
368
+ ) : null}
369
+ </View>
370
+ {isBusy ? (
371
+ <ActivityIndicator color={colors.primary} size="small" />
372
+ ) : isCurrentAccount ? (
373
+ <Ionicons name="checkmark" size={20} color={colors.primary} />
374
+ ) : row.isActive ? (
375
+ // Active sign-in while switched into another account:
376
+ // no current-checkmark, no sign-out (can't sign out the
377
+ // active session here) — tap the row to return to it.
378
+ null
379
+ ) : isRemoving ? (
380
+ <ActivityIndicator color={colors.textSecondary} size="small" />
381
+ ) : (
382
+ <TouchableOpacity
383
+ accessibilityRole="button"
384
+ accessibilityLabel={
385
+ t('accountMenu.signOutAccount', { name: row.displayName })
386
+ || `Sign out ${row.displayName}`
387
+ }
388
+ onPress={() => handleRemoveDevice(row.sessionId)}
389
+ disabled={isSwitching || removingSessionId !== null}
390
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
391
+ style={styles.settingsButton}
392
+ >
393
+ <Ionicons name="log-out-outline" size={18} color={colors.textSecondary} />
394
+ </TouchableOpacity>
395
+ )}
396
+ </TouchableOpacity>
397
+ );
398
+ })}
399
+
400
+ {isSwitching ? (
401
+ <View style={styles.switchingRow}>
402
+ <ActivityIndicator color={colors.textSecondary} size="small" />
403
+ <Text style={[styles.switchingText, { color: colors.textSecondary }]}>
404
+ {t('accountMenu.switching') || 'Switching account…'}
405
+ </Text>
406
+ </View>
407
+ ) : null}
408
+
409
+ <TouchableOpacity
410
+ accessibilityRole="menuitem"
411
+ accessibilityLabel={t('accountMenu.addAnother') || 'Add another account'}
412
+ onPress={() => { onClose(); onAddAccount(); }}
413
+ disabled={actionDisabled}
414
+ activeOpacity={0.6}
415
+ style={[styles.actionRow, actionDisabled && styles.rowDisabled]}
416
+ >
417
+ <Ionicons name="person-add-outline" size={20} color={colors.icon} />
418
+ <Text style={[styles.actionText, { color: colors.text }]}>
419
+ {t('accountMenu.addAnother') || 'Add another account'}
420
+ </Text>
421
+ </TouchableOpacity>
422
+
423
+ {/* Section B — account graph */}
424
+ {accounts.length > 0 ? (
425
+ <>
426
+ <Divider color={colors.border} spacing={4} />
427
+
428
+ {accounts.length > 6 ? (
429
+ <View style={[styles.searchBox, { backgroundColor: colors.card, borderColor: colors.border }]}>
430
+ <Ionicons name="search" size={16} color={colors.textSecondary} />
431
+ <TextInput
432
+ value={query}
433
+ onChangeText={setQuery}
434
+ placeholder={t('accountSwitcher.searchPlaceholder') || 'Search accounts'}
435
+ placeholderTextColor={colors.textSecondary}
436
+ style={[styles.searchInput, { color: colors.text }]}
437
+ autoCapitalize="none"
438
+ autoCorrect={false}
439
+ />
440
+ </View>
441
+ ) : null}
442
+
443
+ {yourAccounts.length > 0 ? (
444
+ <>
445
+ <Text style={[styles.sectionLabel, { color: colors.textSecondary }]}>
446
+ {t('accountSwitcher.sections.yourAccounts') || 'Your accounts'}
447
+ </Text>
448
+ {renderTree(yourAccounts)}
449
+ </>
450
+ ) : null}
451
+
452
+ {sharedAccounts.length > 0 ? (
453
+ <>
454
+ <Text style={[styles.sectionLabel, { color: colors.textSecondary }]}>
455
+ {t('accountSwitcher.sections.sharedWithYou') || 'Shared with you'}
456
+ </Text>
457
+ {renderTree(sharedAccounts)}
458
+ </>
459
+ ) : null}
460
+ </>
461
+ ) : null}
462
+
463
+ <Divider color={colors.border} spacing={4} />
464
+
465
+ {/* Footer actions */}
466
+ <TouchableOpacity
467
+ accessibilityRole="menuitem"
468
+ accessibilityLabel={t('accounts.create.title') || 'Create account'}
469
+ onPress={() => { onClose(); onCreateAccount(); }}
470
+ disabled={actionDisabled}
471
+ activeOpacity={0.6}
472
+ style={[styles.actionRow, actionDisabled && styles.rowDisabled]}
473
+ >
474
+ <Ionicons name="add-circle-outline" size={20} color={colors.icon} />
475
+ <Text style={[styles.actionText, { color: colors.text }]}>
476
+ {t('accounts.create.title') || 'Create account'}
477
+ </Text>
478
+ </TouchableOpacity>
479
+
480
+ <TouchableOpacity
481
+ accessibilityRole="menuitem"
482
+ accessibilityLabel={t('accountMenu.manage') || 'Manage your Oxy Account'}
483
+ onPress={() => { onClose(); onNavigateManage(); }}
484
+ disabled={actionDisabled}
485
+ activeOpacity={0.6}
486
+ style={[styles.actionRow, actionDisabled && styles.rowDisabled]}
487
+ >
488
+ <Ionicons name="settings-outline" size={18} color={colors.icon} />
489
+ <Text style={[styles.actionText, { color: colors.text }]}>
490
+ {t('accountMenu.manage') || 'Manage your Oxy Account'}
491
+ </Text>
492
+ </TouchableOpacity>
493
+
494
+ <TouchableOpacity
495
+ accessibilityRole="menuitem"
496
+ accessibilityLabel={t('common.actions.signOut') || 'Sign out'}
497
+ onPress={() => signOutDialog.open()}
498
+ disabled={actionDisabled}
499
+ activeOpacity={0.6}
500
+ style={[styles.actionRow, actionDisabled && styles.rowDisabled]}
501
+ >
502
+ {signingOut ? (
503
+ <ActivityIndicator color={colors.error} size="small" />
504
+ ) : (
505
+ <Ionicons name="log-out-outline" size={18} color={colors.error} />
506
+ )}
507
+ <Text style={[styles.actionText, { color: colors.error }]}>
508
+ {t('common.actions.signOut') || 'Sign out'}
509
+ </Text>
510
+ </TouchableOpacity>
511
+
512
+ {deviceRows.length > 1 ? (
513
+ <TouchableOpacity
514
+ accessibilityRole="menuitem"
515
+ accessibilityLabel={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
516
+ onPress={() => signOutAllDialog.open()}
517
+ disabled={actionDisabled}
518
+ activeOpacity={0.6}
519
+ style={[styles.actionRow, actionDisabled && styles.rowDisabled]}
520
+ >
521
+ {signingOutAll ? (
522
+ <ActivityIndicator color={colors.error} size="small" />
523
+ ) : (
524
+ <Ionicons name="log-out-outline" size={18} color={colors.error} />
525
+ )}
526
+ <Text style={[styles.actionText, { color: colors.error }]}>
527
+ {t('accountMenu.signOutAll') || 'Sign out of all accounts'}
528
+ </Text>
529
+ </TouchableOpacity>
530
+ ) : null}
531
+
532
+ <Dialog
533
+ control={signOutDialog}
534
+ title={t('common.actions.signOut') || 'Sign out'}
535
+ description={t('common.confirms.signOut') || 'Are you sure you want to sign out?'}
536
+ actions={[
537
+ { label: t('common.actions.signOut') || 'Sign out', color: 'destructive', onPress: performSignOut },
538
+ { label: t('common.cancel') || 'Cancel', color: 'cancel' },
539
+ ]}
540
+ />
541
+ <Dialog
542
+ control={signOutAllDialog}
543
+ title={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
544
+ description={t('common.confirms.signOutAll') || 'Are you sure you want to sign out of all accounts?'}
545
+ actions={[
546
+ { label: t('accountMenu.signOutAll') || 'Sign out of all accounts', color: 'destructive', onPress: performSignOutAll },
547
+ { label: t('common.cancel') || 'Cancel', color: 'cancel' },
548
+ ]}
549
+ />
550
+ </ScrollView>
551
+ );
552
+ };
553
+
554
+ export interface AccountSwitcherProps extends AccountSwitcherActions {
555
+ open: boolean;
556
+ /** Optional anchor (web only). Native ignores this and docks as a bottom sheet. */
557
+ anchor?: AccountMenuAnchor | null;
558
+ }
559
+
560
+ /**
561
+ * Unified account switcher presented as a popover (web) / bottom-sheet style
562
+ * modal (native). The canonical entry point opened by {@link AccountMenuButton}.
563
+ * Supersedes `AccountMenu` (which remains exported as the device-only switcher).
564
+ */
565
+ const AccountSwitcher: React.FC<AccountSwitcherProps> = ({ open, anchor, ...actions }) => {
566
+ const { t } = useI18n();
567
+ const { colors } = useTheme();
568
+ const containerRef = useRef<View | null>(null);
569
+ const { onClose } = actions;
570
+
571
+ useEffect(() => {
572
+ if (!open || !isWeb || typeof document === 'undefined') {
573
+ return undefined;
574
+ }
575
+ const onKey = (event: KeyboardEvent) => {
576
+ if (event.key === 'Escape') {
577
+ event.stopPropagation();
578
+ onClose();
579
+ }
580
+ };
581
+ document.addEventListener('keydown', onKey, true);
582
+ return () => document.removeEventListener('keydown', onKey, true);
583
+ }, [open, onClose]);
584
+
585
+ if (!open) {
586
+ return null;
587
+ }
588
+
589
+ const anchorStyle: ViewStyle = anchor
590
+ ? {
591
+ ...(anchor.top !== undefined ? { top: anchor.top } : null),
592
+ ...(anchor.bottom !== undefined ? { bottom: anchor.bottom } : null),
593
+ ...(anchor.left !== undefined ? { left: anchor.left } : null),
594
+ ...(anchor.right !== undefined ? { right: anchor.right } : null),
595
+ }
596
+ : { top: 64, right: 16 };
597
+
598
+ const panelStyles: ViewStyle[] = isWeb
599
+ ? [styles.panelBase, styles.panelWeb, anchorStyle, { backgroundColor: colors.background, borderColor: colors.border }]
600
+ : [styles.panelBase, styles.panelNative, { backgroundColor: colors.background }];
601
+
602
+ return (
603
+ <Modal visible={open} transparent animationType={isWeb ? 'fade' : 'slide'} onRequestClose={actions.onClose}>
604
+ <Pressable
605
+ accessibilityRole="button"
606
+ accessibilityLabel={t('common.actions.close') || 'Close'}
607
+ onPress={actions.onClose}
608
+ style={isWeb ? styles.webOverlay : styles.nativeOverlay}
609
+ >
610
+ <Pressable
611
+ ref={containerRef}
612
+ onPress={() => undefined}
613
+ style={panelStyles}
614
+ accessibilityRole="menu"
615
+ accessibilityLabel={t('accountSwitcher.label') || 'Account switcher'}
616
+ >
617
+ <AccountSwitcherView {...actions} />
618
+ </Pressable>
619
+ </Pressable>
620
+ </Modal>
621
+ );
622
+ };
623
+
624
+ const styles = StyleSheet.create({
625
+ webOverlay: {
626
+ flex: 1,
627
+ backgroundColor: 'transparent',
628
+ position: 'relative',
629
+ },
630
+ nativeOverlay: {
631
+ flex: 1,
632
+ backgroundColor: 'rgba(0,0,0,0.32)',
633
+ justifyContent: 'flex-end',
634
+ },
635
+ panelBase: {
636
+ borderRadius: 12,
637
+ shadowColor: '#000',
638
+ shadowOpacity: 0.18,
639
+ shadowRadius: 24,
640
+ shadowOffset: { width: 0, height: 8 },
641
+ elevation: 12,
642
+ overflow: 'hidden',
643
+ },
644
+ panelWeb: {
645
+ position: 'absolute',
646
+ width: PANEL_WIDTH,
647
+ maxHeight: '85%',
648
+ borderWidth: 1,
649
+ },
650
+ panelNative: {
651
+ marginHorizontal: 0,
652
+ borderTopLeftRadius: 20,
653
+ borderTopRightRadius: 20,
654
+ paddingBottom: 12,
655
+ maxHeight: '85%',
656
+ },
657
+ scroll: {
658
+ flexGrow: 0,
659
+ },
660
+ scrollContent: {
661
+ paddingVertical: 4,
662
+ },
663
+ sectionLabel: {
664
+ fontSize: 11,
665
+ fontWeight: '600',
666
+ textTransform: 'uppercase',
667
+ letterSpacing: 0.5,
668
+ paddingHorizontal: 14,
669
+ paddingTop: 12,
670
+ paddingBottom: 4,
671
+ },
672
+ accountRow: {
673
+ flexDirection: 'row',
674
+ alignItems: 'center',
675
+ paddingHorizontal: 14,
676
+ paddingVertical: 10,
677
+ gap: 10,
678
+ },
679
+ childRow: {
680
+ paddingLeft: 34,
681
+ },
682
+ rowDisabled: {
683
+ opacity: 0.4,
684
+ },
685
+ accountInfo: {
686
+ flex: 1,
687
+ minWidth: 0,
688
+ },
689
+ nameRow: {
690
+ flexDirection: 'row',
691
+ alignItems: 'center',
692
+ gap: 6,
693
+ },
694
+ accountName: {
695
+ fontSize: 13,
696
+ fontWeight: '500',
697
+ flexShrink: 1,
698
+ },
699
+ accountNameActive: {
700
+ fontWeight: '600',
701
+ },
702
+ accountEmail: {
703
+ fontSize: 11,
704
+ marginTop: 1,
705
+ },
706
+ roleBadge: {
707
+ paddingHorizontal: 6,
708
+ paddingVertical: 1,
709
+ borderRadius: 8,
710
+ },
711
+ roleBadgeText: {
712
+ fontSize: 10,
713
+ fontWeight: '600',
714
+ textTransform: 'capitalize',
715
+ },
716
+ settingsButton: {
717
+ width: 28,
718
+ height: 28,
719
+ alignItems: 'center',
720
+ justifyContent: 'center',
721
+ borderRadius: 14,
722
+ },
723
+ switchingRow: {
724
+ flexDirection: 'row',
725
+ alignItems: 'center',
726
+ justifyContent: 'center',
727
+ paddingVertical: 8,
728
+ gap: 8,
729
+ },
730
+ switchingText: {
731
+ fontSize: 12,
732
+ fontWeight: '500',
733
+ },
734
+ searchBox: {
735
+ flexDirection: 'row',
736
+ alignItems: 'center',
737
+ gap: 8,
738
+ marginHorizontal: 14,
739
+ marginVertical: 6,
740
+ paddingHorizontal: 10,
741
+ height: 36,
742
+ borderRadius: 10,
743
+ borderWidth: 1,
744
+ },
745
+ searchInput: {
746
+ flex: 1,
747
+ fontSize: 13,
748
+ padding: 0,
749
+ },
750
+ actionRow: {
751
+ flexDirection: 'row',
752
+ alignItems: 'center',
753
+ paddingHorizontal: 14,
754
+ paddingVertical: 12,
755
+ gap: 10,
756
+ },
757
+ actionText: {
758
+ fontSize: 13,
759
+ fontWeight: '500',
760
+ },
761
+ });
762
+
763
+ export default AccountSwitcher;