@oxyhq/services 13.0.3 → 13.1.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.
- package/lib/commonjs/index.js +18 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/ProfileButton.js +233 -0
- package/lib/commonjs/ui/components/ProfileButton.js.map +1 -0
- package/lib/commonjs/ui/components/ProfileMenu.js +374 -0
- package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -0
- package/lib/commonjs/ui/components/SettingsIcon.js.map +1 -1
- package/lib/commonjs/ui/components/fileManagement/FileViewer.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +64 -11
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +28 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/index.js +14 -0
- package/lib/commonjs/ui/index.js.map +1 -1
- package/lib/commonjs/ui/screens/FeedbackScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/activeAuthuser.js +47 -0
- package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/ProfileButton.js +228 -0
- package/lib/module/ui/components/ProfileButton.js.map +1 -0
- package/lib/module/ui/components/ProfileMenu.js +370 -0
- package/lib/module/ui/components/ProfileMenu.js.map +1 -0
- package/lib/module/ui/components/SettingsIcon.js.map +1 -1
- package/lib/module/ui/components/fileManagement/FileViewer.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +65 -12
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +29 -2
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/index.js +2 -0
- package/lib/module/ui/index.js.map +1 -1
- package/lib/module/ui/screens/FeedbackScreen.js.map +1 -1
- package/lib/module/ui/utils/activeAuthuser.js +45 -1
- package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts +46 -0
- package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +39 -0
- package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/SettingsIcon.d.ts +2 -1
- package/lib/typescript/commonjs/ui/components/SettingsIcon.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/payment/types.d.ts +3 -1
- package/lib/typescript/commonjs/ui/components/payment/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts +2 -0
- package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +24 -0
- package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ProfileButton.d.ts +46 -0
- package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/ProfileMenu.d.ts +39 -0
- package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/SettingsIcon.d.ts +2 -1
- package/lib/typescript/module/ui/components/SettingsIcon.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/payment/types.d.ts +3 -1
- package/lib/typescript/module/ui/components/payment/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/index.d.ts +2 -0
- package/lib/typescript/module/ui/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +24 -0
- package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +8 -0
- package/src/ui/components/ProfileButton.tsx +270 -0
- package/src/ui/components/ProfileMenu.tsx +416 -0
- package/src/ui/components/SettingsIcon.tsx +2 -2
- package/src/ui/components/fileManagement/FileViewer.tsx +1 -1
- package/src/ui/components/payment/types.ts +3 -1
- package/src/ui/context/OxyContext.tsx +65 -11
- package/src/ui/context/hooks/useAuthOperations.ts +35 -2
- package/src/ui/index.ts +2 -0
- package/src/ui/screens/FeedbackScreen.tsx +3 -3
- package/src/ui/utils/activeAuthuser.ts +46 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
View,
|
|
5
|
+
Pressable,
|
|
6
|
+
Modal,
|
|
7
|
+
ScrollView,
|
|
8
|
+
ActivityIndicator,
|
|
9
|
+
Platform,
|
|
10
|
+
type ViewStyle,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
13
|
+
import { Avatar } from '@oxyhq/bloom/avatar';
|
|
14
|
+
import { Text } from '@oxyhq/bloom/typography';
|
|
15
|
+
import { useTheme } from '@oxyhq/bloom/theme';
|
|
16
|
+
import { toast, useDialogControl, Dialog } from '@oxyhq/bloom';
|
|
17
|
+
import { Divider } from '@oxyhq/bloom/divider';
|
|
18
|
+
import { isDev, logger as loggerUtil } from '@oxyhq/core';
|
|
19
|
+
import { useOxy } from '../context/OxyContext';
|
|
20
|
+
import { useI18n } from '../hooks/useI18n';
|
|
21
|
+
import { useDeviceAccounts } from '../hooks/useDeviceAccounts';
|
|
22
|
+
|
|
23
|
+
const isWeb = Platform.OS === 'web';
|
|
24
|
+
|
|
25
|
+
/** Fixed popover width on web. `ProfileButton` mirrors this when anchoring. */
|
|
26
|
+
const MENU_WIDTH = 300;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Web-only anchor. `ProfileButton` measures its trigger and anchors the panel's
|
|
30
|
+
* BOTTOM-LEFT corner so the menu opens UPWARD from the sidebar footer. Native
|
|
31
|
+
* ignores the anchor and docks the panel to the bottom as a sheet.
|
|
32
|
+
*/
|
|
33
|
+
export interface ProfileMenuAnchor {
|
|
34
|
+
/** Distance from the viewport left, for the panel's LEFT edge. */
|
|
35
|
+
left: number;
|
|
36
|
+
/** Distance from the viewport bottom, for the panel's BOTTOM edge (opens upward). */
|
|
37
|
+
bottom: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProfileMenuProps {
|
|
41
|
+
open: boolean;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
/** Web-only trigger anchor. Native ignores this (bottom-sheet style). */
|
|
44
|
+
anchor?: ProfileMenuAnchor | null;
|
|
45
|
+
/** Navigate to the "Manage account" surface (settings). */
|
|
46
|
+
onNavigateManage: () => void;
|
|
47
|
+
/** Start the add-account / sign-in flow for an additional account. */
|
|
48
|
+
onAddAccount: () => void;
|
|
49
|
+
/** Optional: navigate to the signed-in user's own profile. */
|
|
50
|
+
onNavigateProfile?: () => void;
|
|
51
|
+
/** Called before the active identity changes so apps can clear scoped state. */
|
|
52
|
+
onBeforeSessionChange?: () => void | Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Clean device-account switcher, modeled on the inbox `AccountMenu` but written
|
|
57
|
+
* fresh with NativeWind classNames + Bloom primitives. Lists every account
|
|
58
|
+
* signed in on this device (from {@link useDeviceAccounts}); tapping a row
|
|
59
|
+
* switches, and each inactive row carries a sign-out icon. Below the list:
|
|
60
|
+
* Add account, Manage account, optional View profile, and Sign out of all.
|
|
61
|
+
*
|
|
62
|
+
* Renders as an upward-opening popover anchored to the trigger on web, and a
|
|
63
|
+
* bottom-sheet style modal on native.
|
|
64
|
+
*/
|
|
65
|
+
const ProfileMenu: React.FC<ProfileMenuProps> = ({
|
|
66
|
+
open,
|
|
67
|
+
onClose,
|
|
68
|
+
anchor,
|
|
69
|
+
onNavigateManage,
|
|
70
|
+
onAddAccount,
|
|
71
|
+
onNavigateProfile,
|
|
72
|
+
onBeforeSessionChange,
|
|
73
|
+
}) => {
|
|
74
|
+
const {
|
|
75
|
+
activeSessionId,
|
|
76
|
+
switchSession,
|
|
77
|
+
logoutAll,
|
|
78
|
+
removeSession,
|
|
79
|
+
} = useOxy();
|
|
80
|
+
const { t } = useI18n();
|
|
81
|
+
const { colors } = useTheme();
|
|
82
|
+
|
|
83
|
+
const { accounts } = useDeviceAccounts();
|
|
84
|
+
|
|
85
|
+
const [busySessionId, setBusySessionId] = useState<string | null>(null);
|
|
86
|
+
const [removingSessionId, setRemovingSessionId] = useState<string | null>(null);
|
|
87
|
+
const [signingOutAll, setSigningOutAll] = useState(false);
|
|
88
|
+
|
|
89
|
+
const signOutAllDialog = useDialogControl();
|
|
90
|
+
|
|
91
|
+
const isSwitching = busySessionId !== null;
|
|
92
|
+
const actionDisabled = isSwitching || removingSessionId !== null || signingOutAll;
|
|
93
|
+
|
|
94
|
+
// Switch to a non-active account through the SDK's canonical switch path.
|
|
95
|
+
const handleSwitch = useCallback(async (sessionId: string) => {
|
|
96
|
+
if (sessionId === activeSessionId || busySessionId) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setBusySessionId(sessionId);
|
|
100
|
+
try {
|
|
101
|
+
await onBeforeSessionChange?.();
|
|
102
|
+
await switchSession(sessionId);
|
|
103
|
+
toast.success(t('accountSwitcher.toasts.switchSuccess') || 'Switched account');
|
|
104
|
+
onClose();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (!isDev()) {
|
|
107
|
+
loggerUtil.warn('Switch account failed', { component: 'ProfileMenu' }, error as unknown);
|
|
108
|
+
}
|
|
109
|
+
toast.error(t('accountSwitcher.toasts.switchFailed') || 'Failed to switch account');
|
|
110
|
+
} finally {
|
|
111
|
+
setBusySessionId(null);
|
|
112
|
+
}
|
|
113
|
+
}, [activeSessionId, busySessionId, switchSession, t, onClose, onBeforeSessionChange]);
|
|
114
|
+
|
|
115
|
+
// Sign out a specific inactive account without switching/clearing the active
|
|
116
|
+
// one. The menu stays open so the user can keep managing accounts.
|
|
117
|
+
const handleRemove = useCallback(async (sessionId: string) => {
|
|
118
|
+
if (sessionId === activeSessionId || removingSessionId) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setRemovingSessionId(sessionId);
|
|
122
|
+
try {
|
|
123
|
+
await removeSession(sessionId);
|
|
124
|
+
toast.success(t('common.actions.signedOut') || 'Signed out');
|
|
125
|
+
} catch (error) {
|
|
126
|
+
loggerUtil.warn('Remove account failed', { component: 'ProfileMenu' }, error as unknown);
|
|
127
|
+
toast.error(t('common.errors.signOutFailed') || 'Failed to sign out');
|
|
128
|
+
} finally {
|
|
129
|
+
setRemovingSessionId(null);
|
|
130
|
+
}
|
|
131
|
+
}, [activeSessionId, removingSessionId, removeSession, t]);
|
|
132
|
+
|
|
133
|
+
const performSignOutAll = useCallback(async () => {
|
|
134
|
+
if (signingOutAll) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setSigningOutAll(true);
|
|
138
|
+
try {
|
|
139
|
+
await onBeforeSessionChange?.();
|
|
140
|
+
await logoutAll();
|
|
141
|
+
toast.success(t('accountSwitcher.toasts.signOutAllSuccess') || 'Signed out of all accounts');
|
|
142
|
+
onClose();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
loggerUtil.warn('Sign out all failed', { component: 'ProfileMenu' }, error as unknown);
|
|
145
|
+
toast.error(t('common.errors.signOutAllFailed') || 'Failed to sign out of all accounts');
|
|
146
|
+
} finally {
|
|
147
|
+
setSigningOutAll(false);
|
|
148
|
+
}
|
|
149
|
+
}, [signingOutAll, logoutAll, t, onClose, onBeforeSessionChange]);
|
|
150
|
+
|
|
151
|
+
// Escape-to-close (web only).
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!open || !isWeb || typeof document === 'undefined') {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
const onKey = (event: KeyboardEvent) => {
|
|
157
|
+
if (event.key === 'Escape') {
|
|
158
|
+
event.stopPropagation();
|
|
159
|
+
onClose();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
document.addEventListener('keydown', onKey, true);
|
|
163
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
164
|
+
}, [open, onClose]);
|
|
165
|
+
|
|
166
|
+
if (!open) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Web: anchor the panel's bottom-left corner to the measured trigger, falling
|
|
171
|
+
// back to a bottom-left placement when no anchor was captured. Native ignores
|
|
172
|
+
// this (the panel docks to the bottom via the overlay's flex-end).
|
|
173
|
+
const panelAnchorStyle: ViewStyle | undefined = isWeb
|
|
174
|
+
? {
|
|
175
|
+
position: 'absolute',
|
|
176
|
+
width: MENU_WIDTH,
|
|
177
|
+
left: anchor?.left ?? 8,
|
|
178
|
+
bottom: anchor?.bottom ?? 8,
|
|
179
|
+
}
|
|
180
|
+
: undefined;
|
|
181
|
+
|
|
182
|
+
const content = (
|
|
183
|
+
<Pressable
|
|
184
|
+
// Swallow taps inside the panel so they never reach the overlay's
|
|
185
|
+
// outside-tap-to-close handler.
|
|
186
|
+
onPress={() => undefined}
|
|
187
|
+
className={
|
|
188
|
+
isWeb
|
|
189
|
+
? 'overflow-hidden rounded-2xl border border-border bg-background'
|
|
190
|
+
: 'overflow-hidden rounded-t-3xl bg-background pb-3'
|
|
191
|
+
}
|
|
192
|
+
style={[
|
|
193
|
+
panelAnchorStyle,
|
|
194
|
+
!isWeb ? { maxHeight: '85%' } : { maxHeight: '85%' },
|
|
195
|
+
styles.shadow,
|
|
196
|
+
]}
|
|
197
|
+
accessibilityRole="menu"
|
|
198
|
+
accessibilityLabel={t('accountMenu.label') || 'Account menu'}
|
|
199
|
+
>
|
|
200
|
+
<ScrollView
|
|
201
|
+
className="grow-0"
|
|
202
|
+
contentContainerClassName="py-1"
|
|
203
|
+
showsVerticalScrollIndicator={false}
|
|
204
|
+
>
|
|
205
|
+
{/* 1) Device accounts — active first (checkmark), others switchable. */}
|
|
206
|
+
{accounts.map((account) => {
|
|
207
|
+
const isActive = account.isCurrent;
|
|
208
|
+
const isBusy = busySessionId === account.sessionId;
|
|
209
|
+
const isRemoving = removingSessionId === account.sessionId;
|
|
210
|
+
return (
|
|
211
|
+
<Pressable
|
|
212
|
+
key={`account-${account.sessionId}`}
|
|
213
|
+
accessibilityRole="menuitem"
|
|
214
|
+
accessibilityLabel={account.displayName}
|
|
215
|
+
accessibilityState={{ selected: isActive }}
|
|
216
|
+
onPress={() => handleSwitch(account.sessionId)}
|
|
217
|
+
disabled={isActive || isBusy || isSwitching}
|
|
218
|
+
className={`flex-row items-center gap-3 px-4 py-2.5 ${
|
|
219
|
+
isActive ? 'bg-secondary' : ''
|
|
220
|
+
} ${isSwitching && !isActive ? 'opacity-40' : ''}`}
|
|
221
|
+
>
|
|
222
|
+
<Avatar
|
|
223
|
+
source={account.user.avatar ?? undefined}
|
|
224
|
+
uri={account.avatarUrl}
|
|
225
|
+
variant="thumb"
|
|
226
|
+
name={account.displayName}
|
|
227
|
+
size={isActive ? 40 : 32}
|
|
228
|
+
/>
|
|
229
|
+
<View className="min-w-0 flex-1">
|
|
230
|
+
<Text
|
|
231
|
+
className={`text-foreground ${isActive ? 'font-semibold' : 'font-medium'}`}
|
|
232
|
+
numberOfLines={1}
|
|
233
|
+
>
|
|
234
|
+
{account.displayName}
|
|
235
|
+
</Text>
|
|
236
|
+
{account.email ? (
|
|
237
|
+
<Text
|
|
238
|
+
className="text-xs text-muted-foreground"
|
|
239
|
+
numberOfLines={1}
|
|
240
|
+
>
|
|
241
|
+
{account.email}
|
|
242
|
+
</Text>
|
|
243
|
+
) : null}
|
|
244
|
+
</View>
|
|
245
|
+
{isBusy ? (
|
|
246
|
+
<ActivityIndicator color={colors.primary} size="small" />
|
|
247
|
+
) : isActive ? (
|
|
248
|
+
<MaterialCommunityIcons name="check" size={20} color={colors.primary} />
|
|
249
|
+
) : isRemoving ? (
|
|
250
|
+
<ActivityIndicator color={colors.textSecondary} size="small" />
|
|
251
|
+
) : (
|
|
252
|
+
<Pressable
|
|
253
|
+
accessibilityRole="button"
|
|
254
|
+
accessibilityLabel={
|
|
255
|
+
t('accountMenu.signOutAccount', { name: account.displayName })
|
|
256
|
+
|| `Sign out ${account.displayName}`
|
|
257
|
+
}
|
|
258
|
+
onPress={() => handleRemove(account.sessionId)}
|
|
259
|
+
disabled={actionDisabled}
|
|
260
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
261
|
+
className="h-7 w-7 items-center justify-center rounded-full opacity-60"
|
|
262
|
+
>
|
|
263
|
+
<MaterialCommunityIcons
|
|
264
|
+
name="logout"
|
|
265
|
+
size={18}
|
|
266
|
+
color={colors.textSecondary}
|
|
267
|
+
/>
|
|
268
|
+
</Pressable>
|
|
269
|
+
)}
|
|
270
|
+
</Pressable>
|
|
271
|
+
);
|
|
272
|
+
})}
|
|
273
|
+
|
|
274
|
+
{/* 2) Switching indicator. */}
|
|
275
|
+
{isSwitching ? (
|
|
276
|
+
<View className="flex-row items-center justify-center gap-2 py-2">
|
|
277
|
+
<ActivityIndicator color={colors.textSecondary} size="small" />
|
|
278
|
+
<Text className="text-xs font-medium text-muted-foreground">
|
|
279
|
+
{t('accountMenu.switching') || 'Switching account…'}
|
|
280
|
+
</Text>
|
|
281
|
+
</View>
|
|
282
|
+
) : null}
|
|
283
|
+
|
|
284
|
+
<Divider color={colors.border} spacing={4} />
|
|
285
|
+
|
|
286
|
+
{/* 3) Add another account. */}
|
|
287
|
+
<ActionRow
|
|
288
|
+
icon="account-plus-outline"
|
|
289
|
+
iconColor={colors.icon}
|
|
290
|
+
label={t('accountMenu.addAnother') || 'Add another account'}
|
|
291
|
+
disabled={actionDisabled}
|
|
292
|
+
onPress={() => {
|
|
293
|
+
onClose();
|
|
294
|
+
onAddAccount();
|
|
295
|
+
}}
|
|
296
|
+
/>
|
|
297
|
+
|
|
298
|
+
{/* 4) Manage account. */}
|
|
299
|
+
<ActionRow
|
|
300
|
+
icon="cog-outline"
|
|
301
|
+
iconColor={colors.icon}
|
|
302
|
+
label={t('accountMenu.manage') || 'Manage your Oxy Account'}
|
|
303
|
+
disabled={actionDisabled}
|
|
304
|
+
onPress={() => {
|
|
305
|
+
onClose();
|
|
306
|
+
onNavigateManage();
|
|
307
|
+
}}
|
|
308
|
+
/>
|
|
309
|
+
|
|
310
|
+
{/* 5) View profile (optional). */}
|
|
311
|
+
{onNavigateProfile ? (
|
|
312
|
+
<ActionRow
|
|
313
|
+
icon="account-outline"
|
|
314
|
+
iconColor={colors.icon}
|
|
315
|
+
label={t('accountMenu.viewProfile') || 'View profile'}
|
|
316
|
+
disabled={actionDisabled}
|
|
317
|
+
onPress={() => {
|
|
318
|
+
onClose();
|
|
319
|
+
onNavigateProfile();
|
|
320
|
+
}}
|
|
321
|
+
/>
|
|
322
|
+
) : null}
|
|
323
|
+
|
|
324
|
+
{/* 6) Sign out of all accounts. */}
|
|
325
|
+
<Divider color={colors.border} spacing={4} />
|
|
326
|
+
<Pressable
|
|
327
|
+
accessibilityRole="menuitem"
|
|
328
|
+
accessibilityLabel={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
|
|
329
|
+
onPress={() => signOutAllDialog.open()}
|
|
330
|
+
disabled={actionDisabled}
|
|
331
|
+
className={`flex-row items-center gap-3 px-4 py-3 ${actionDisabled ? 'opacity-40' : ''}`}
|
|
332
|
+
>
|
|
333
|
+
{signingOutAll ? (
|
|
334
|
+
<ActivityIndicator color={colors.error} size="small" />
|
|
335
|
+
) : (
|
|
336
|
+
<MaterialCommunityIcons name="logout" size={18} color={colors.error} />
|
|
337
|
+
)}
|
|
338
|
+
<Text className="font-medium" style={{ color: colors.error }}>
|
|
339
|
+
{t('accountMenu.signOutAll') || 'Sign out of all accounts'}
|
|
340
|
+
</Text>
|
|
341
|
+
</Pressable>
|
|
342
|
+
</ScrollView>
|
|
343
|
+
</Pressable>
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<Modal
|
|
348
|
+
visible={open}
|
|
349
|
+
transparent
|
|
350
|
+
animationType={isWeb ? 'fade' : 'slide'}
|
|
351
|
+
onRequestClose={onClose}
|
|
352
|
+
>
|
|
353
|
+
<Pressable
|
|
354
|
+
accessibilityRole="button"
|
|
355
|
+
accessibilityLabel={t('common.actions.close') || 'Close'}
|
|
356
|
+
onPress={onClose}
|
|
357
|
+
className={isWeb ? 'flex-1 relative' : 'flex-1 justify-end'}
|
|
358
|
+
style={!isWeb ? styles.nativeScrim : undefined}
|
|
359
|
+
>
|
|
360
|
+
{content}
|
|
361
|
+
</Pressable>
|
|
362
|
+
|
|
363
|
+
<Dialog
|
|
364
|
+
control={signOutAllDialog}
|
|
365
|
+
title={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
|
|
366
|
+
description={t('common.confirms.signOutAll') || 'Are you sure you want to sign out of all accounts?'}
|
|
367
|
+
actions={[
|
|
368
|
+
{
|
|
369
|
+
label: t('accountMenu.signOutAll') || 'Sign out of all accounts',
|
|
370
|
+
color: 'destructive',
|
|
371
|
+
onPress: performSignOutAll,
|
|
372
|
+
},
|
|
373
|
+
{ label: t('common.cancel') || 'Cancel', color: 'cancel' },
|
|
374
|
+
]}
|
|
375
|
+
/>
|
|
376
|
+
</Modal>
|
|
377
|
+
);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/** Bottom-section action row (Add account / Manage / View profile). */
|
|
381
|
+
const ActionRow: React.FC<{
|
|
382
|
+
icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
|
|
383
|
+
iconColor: string;
|
|
384
|
+
label: string;
|
|
385
|
+
disabled: boolean;
|
|
386
|
+
onPress: () => void;
|
|
387
|
+
}> = ({ icon, iconColor, label, disabled, onPress }) => (
|
|
388
|
+
<Pressable
|
|
389
|
+
accessibilityRole="menuitem"
|
|
390
|
+
accessibilityLabel={label}
|
|
391
|
+
onPress={onPress}
|
|
392
|
+
disabled={disabled}
|
|
393
|
+
className={`flex-row items-center gap-3 px-4 py-3 ${disabled ? 'opacity-40' : ''}`}
|
|
394
|
+
>
|
|
395
|
+
<MaterialCommunityIcons name={icon} size={20} color={iconColor} />
|
|
396
|
+
<Text className="font-medium text-foreground">{label}</Text>
|
|
397
|
+
</Pressable>
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// The panel's drop shadow and the native scrim are the only values with no
|
|
401
|
+
// NativeWind class equivalent in this package (dynamic elevation + rgba scrim),
|
|
402
|
+
// so they stay as small inline objects rather than raw class-replaceable styles.
|
|
403
|
+
const styles = {
|
|
404
|
+
shadow: {
|
|
405
|
+
shadowColor: '#000',
|
|
406
|
+
shadowOpacity: 0.18,
|
|
407
|
+
shadowRadius: 24,
|
|
408
|
+
shadowOffset: { width: 0, height: 8 },
|
|
409
|
+
elevation: 12,
|
|
410
|
+
} satisfies ViewStyle,
|
|
411
|
+
nativeScrim: {
|
|
412
|
+
backgroundColor: 'rgba(0,0,0,0.32)',
|
|
413
|
+
} satisfies ViewStyle,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
export default ProfileMenu;
|
|
@@ -5,7 +5,7 @@ import { darkenColor } from '../utils/colorUtils';
|
|
|
5
5
|
|
|
6
6
|
interface SettingsIconProps {
|
|
7
7
|
/** MaterialCommunityIcons icon name */
|
|
8
|
-
name:
|
|
8
|
+
name: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
|
|
9
9
|
/** Background color for the circle */
|
|
10
10
|
color: string;
|
|
11
11
|
/** Icon size (default 14, fits bloom's 20x20 container) */
|
|
@@ -26,7 +26,7 @@ const SettingsIconComponent: React.FC<SettingsIconProps> = ({
|
|
|
26
26
|
size = 20,
|
|
27
27
|
}) => (
|
|
28
28
|
<View style={[styles.circle, { width: size, height: size, borderRadius: size / 2, backgroundColor: color }]}>
|
|
29
|
-
<MaterialCommunityIcons name={name
|
|
29
|
+
<MaterialCommunityIcons name={name} size={iconSize} color={darkenColor(color)} />
|
|
30
30
|
</View>
|
|
31
31
|
);
|
|
32
32
|
|
|
@@ -102,7 +102,7 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
|
|
102
102
|
}, [imageDimensions, containerWidth]);
|
|
103
103
|
|
|
104
104
|
const fileDetailItems = useMemo(() => {
|
|
105
|
-
const items: Array<{ id: string; iconName:
|
|
105
|
+
const items: Array<{ id: string; iconName: React.ComponentProps<typeof MaterialCommunityIcons>['name']; iconColor: string; title: string; description: string }> = [
|
|
106
106
|
{
|
|
107
107
|
id: 'filename',
|
|
108
108
|
iconName: 'file-document',
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type React from 'react';
|
|
1
2
|
import type { Animated } from 'react-native';
|
|
3
|
+
import type { Ionicons } from '@expo/vector-icons';
|
|
2
4
|
|
|
3
5
|
export type PaymentItem = {
|
|
4
6
|
type: 'product' | 'subscription' | 'service' | 'fee' | string;
|
|
@@ -25,7 +27,7 @@ export interface CardDetails {
|
|
|
25
27
|
export interface PaymentMethod {
|
|
26
28
|
key: string;
|
|
27
29
|
label: string;
|
|
28
|
-
icon:
|
|
30
|
+
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
29
31
|
description: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -43,7 +43,7 @@ import { useAuthOperations } from './hooks/useAuthOperations';
|
|
|
43
43
|
import { useDeviceManagement } from '../hooks/useDeviceManagement';
|
|
44
44
|
import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
|
|
45
45
|
import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
|
|
46
|
-
import { readActiveAuthuser, writeActiveAuthuser } from '../utils/activeAuthuser';
|
|
46
|
+
import { readActiveAuthuser, writeActiveAuthuser, clearSignedOut, isSilentRestoreSuppressed } from '../utils/activeAuthuser';
|
|
47
47
|
import type { RouteName } from '../navigation/routes';
|
|
48
48
|
import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
|
|
49
49
|
import { useQueryClient } from '@tanstack/react-query';
|
|
@@ -1322,16 +1322,30 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1322
1322
|
// run cookie-restore BEFORE those probes (and disable the later duplicate).
|
|
1323
1323
|
//
|
|
1324
1324
|
// Read ONCE here (synchronously usable by the step `enabled` gates below).
|
|
1325
|
-
// It is non-null ONLY on first-party
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
// a slot
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1325
|
+
// It is non-null ONLY on first-party web apps that persist the slot. Both the
|
|
1326
|
+
// device sign-in/switch paths AND `handleWebSSOSession` (FedCM/SSO/credentials
|
|
1327
|
+
// commit funnel) now persist it — but only when the session genuinely joined
|
|
1328
|
+
// the device set: `establishDeviceRefreshSlot` returns an `authuser` ONLY on
|
|
1329
|
+
// first-party web (same registrable apex as the API), so a cross-apex RP never
|
|
1330
|
+
// writes a phantom slot and native has no localStorage. Both therefore keep
|
|
1331
|
+
// their existing order untouched. The earlier `stored-session` step still runs
|
|
1332
|
+
// first (FIX-A latency), and when a slot exists but its cookies have all
|
|
1333
|
+
// lapsed, cookie-restore simply returns no accounts and the chain falls through
|
|
1334
|
+
// to the cross-domain fallbacks exactly as before.
|
|
1333
1335
|
const prioritizeMultiAccount = isWebBrowser() && readActiveAuthuser() !== null;
|
|
1334
1336
|
|
|
1337
|
+
// DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", the
|
|
1338
|
+
// central IdP session (FedCM credential association / per-apex `fedcm_session`
|
|
1339
|
+
// cookie) can still be live, so the AUTOMATIC silent steps below
|
|
1340
|
+
// (`fedcm-silent`, `silent-iframe`) would re-mint a session on the very next
|
|
1341
|
+
// cold boot and sign the user back in without intent. Read the durable flag
|
|
1342
|
+
// ONCE here (synchronously usable by the step `enabled` gates) and skip those
|
|
1343
|
+
// two steps while it is set. Any deliberate sign-in clears it. The `sso-bounce`
|
|
1344
|
+
// step is already self-suppressed after sign-out (its `hasPriorSession` hint is
|
|
1345
|
+
// cleared), and the local restore steps (`stored-session`, cookie-restore) are
|
|
1346
|
+
// unaffected — a deliberate sign-out clears those credentials anyway.
|
|
1347
|
+
const silentRestoreBlocked = isSilentRestoreSuppressed();
|
|
1348
|
+
|
|
1335
1349
|
try {
|
|
1336
1350
|
const outcome = await runColdBoot<true>({
|
|
1337
1351
|
steps: [
|
|
@@ -1461,7 +1475,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1461
1475
|
// valid local bearer, and skipping it avoids the silent round-trip.
|
|
1462
1476
|
id: 'fedcm-silent',
|
|
1463
1477
|
enabled: () =>
|
|
1464
|
-
!storedSessionRestored &&
|
|
1478
|
+
!storedSessionRestored &&
|
|
1479
|
+
!silentRestoreBlocked &&
|
|
1480
|
+
fedcmSupported &&
|
|
1481
|
+
!servicesSilentAttempted.has(silentKey),
|
|
1465
1482
|
run: async () => {
|
|
1466
1483
|
servicesSilentAttempted.add(silentKey);
|
|
1467
1484
|
const session = await oxyServices.silentSignInWithFedCM?.();
|
|
@@ -1496,7 +1513,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1496
1513
|
// FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
|
|
1497
1514
|
// fail-fast in core) so a no-message iframe cannot stall cold boot.
|
|
1498
1515
|
id: 'silent-iframe',
|
|
1499
|
-
enabled: () => !storedSessionRestored && isWebBrowser(),
|
|
1516
|
+
enabled: () => !storedSessionRestored && !silentRestoreBlocked && isWebBrowser(),
|
|
1500
1517
|
run: async () => {
|
|
1501
1518
|
if (!commitWebSession) {
|
|
1502
1519
|
return { kind: 'skip' };
|
|
@@ -1779,6 +1796,38 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1779
1796
|
}
|
|
1780
1797
|
oxyServices.httpService.setTokens(session.accessToken);
|
|
1781
1798
|
|
|
1799
|
+
// A committed web session re-enables automatic silent restore: clear the
|
|
1800
|
+
// durable "deliberately signed out" flag. This funnel is reached by deliberate
|
|
1801
|
+
// sign-ins (password, interactive FedCM, `/sso` return) AND by the silent
|
|
1802
|
+
// cold-boot steps — but those are GATED on the flag, so when it is set they
|
|
1803
|
+
// never run and never reach here; clearing is therefore only hit on a genuine
|
|
1804
|
+
// (re-)sign-in or when restore was already permitted, both correct.
|
|
1805
|
+
clearSignedOut();
|
|
1806
|
+
|
|
1807
|
+
// Register this primary session in the device's first-party multi-account
|
|
1808
|
+
// refresh-cookie set (web only). Every web primary-session restore funnels
|
|
1809
|
+
// through here — FedCM silent (`/fedcm/exchange`), per-apex `/auth/silent`
|
|
1810
|
+
// iframe, central `/sso` return, and keyless password sign-in. Of those, only
|
|
1811
|
+
// a same-apex `/fedcm/exchange` plants an `oxy_rt_<authuser>` slot as a side
|
|
1812
|
+
// effect; the cross-origin/credential-less restores (`/sso/exchange`,
|
|
1813
|
+
// `/auth/silent` postMessage) cannot set an `api.oxy.so` cookie at all. So
|
|
1814
|
+
// WITHOUT this call a web primary never joins the device set: `refresh-all`
|
|
1815
|
+
// returns zero accounts, and account-switch persistence + the
|
|
1816
|
+
// `cookie-restore-active` cold-boot step have no foundation. Calling the shared
|
|
1817
|
+
// `POST /auth/session` primitive here makes EVERY primary participate in the
|
|
1818
|
+
// set, re-plants the rotated token, and records the active `authuser` so the
|
|
1819
|
+
// next cold boot's `cookie-restore-active` reconciles the persisted active
|
|
1820
|
+
// (possibly switched) account instead of `fedcm-silent` clobbering it back to
|
|
1821
|
+
// the primary. Best-effort: a `null` result (native / transient failure)
|
|
1822
|
+
// leaves the in-session token untouched.
|
|
1823
|
+
const primaryAuthuser = await oxyServices.establishDeviceRefreshSlot();
|
|
1824
|
+
// A non-null slot is returned ONLY on first-party web (the helper gates on
|
|
1825
|
+
// platform + same registrable apex), so recording the active slot here is safe
|
|
1826
|
+
// and never writes a phantom authuser for a cross-apex RP or native.
|
|
1827
|
+
if (typeof primaryAuthuser === 'number') {
|
|
1828
|
+
writeActiveAuthuser(primaryAuthuser);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1782
1831
|
const clientSession = {
|
|
1783
1832
|
sessionId: session.sessionId,
|
|
1784
1833
|
deviceId: session.deviceId || '',
|
|
@@ -1786,6 +1835,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1786
1835
|
lastActive: new Date().toISOString(),
|
|
1787
1836
|
userId: session.user.id?.toString() ?? '',
|
|
1788
1837
|
isCurrent: true,
|
|
1838
|
+
...(typeof primaryAuthuser === 'number' ? { authuser: primaryAuthuser } : null),
|
|
1789
1839
|
};
|
|
1790
1840
|
|
|
1791
1841
|
updateSessions([clientSession], { merge: true });
|
|
@@ -2099,6 +2149,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2099
2149
|
throw new Error('Account switch did not return a valid session');
|
|
2100
2150
|
}
|
|
2101
2151
|
|
|
2152
|
+
// A switch is a deliberate sign-in into an account: re-enable automatic silent
|
|
2153
|
+
// restore by clearing any prior "deliberately signed out" flag.
|
|
2154
|
+
clearSignedOut();
|
|
2155
|
+
|
|
2102
2156
|
// `oxyServices.switchToAccount` already planted `result.accessToken` as the
|
|
2103
2157
|
// active token; mirror the minted session into the multi-account store and
|
|
2104
2158
|
// mark it current, recording the device `authuser` slot so web silent-switch
|
|
@@ -9,7 +9,13 @@ import type { StorageInterface } from '../../utils/storageHelpers';
|
|
|
9
9
|
import type { OxyServices } from '@oxyhq/core';
|
|
10
10
|
import { SignatureService } from '@oxyhq/core';
|
|
11
11
|
import { isWebBrowser } from '../../hooks/useWebSSO';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
clearActiveAuthuser,
|
|
14
|
+
clearSsoBounceState,
|
|
15
|
+
writeActiveAuthuser,
|
|
16
|
+
markSignedOut,
|
|
17
|
+
clearSignedOut,
|
|
18
|
+
} from '../../utils/activeAuthuser';
|
|
13
19
|
import { isCrossApexWeb, CrossApexDirectSignInError } from '../../../utils/crossApex';
|
|
14
20
|
|
|
15
21
|
export interface UseAuthOperationsOptions {
|
|
@@ -133,6 +139,23 @@ export const useAuthOperations = ({
|
|
|
133
139
|
deviceFingerprint,
|
|
134
140
|
);
|
|
135
141
|
|
|
142
|
+
// Deliberate sign-in re-enables automatic silent restore: clear the durable
|
|
143
|
+
// "deliberately signed out" flag so a prior sign-out no longer suppresses
|
|
144
|
+
// the `fedcm-silent` / per-apex iframe cold-boot steps.
|
|
145
|
+
clearSignedOut();
|
|
146
|
+
|
|
147
|
+
// Register this primary session in the device's first-party multi-account
|
|
148
|
+
// refresh-cookie set so a web key-based sign-in survives reload via
|
|
149
|
+
// `refresh-all` and coexists with switched accounts. `/auth/verify` mints the
|
|
150
|
+
// session but (unlike `/auth/login` / `/fedcm/exchange`) does NOT plant an
|
|
151
|
+
// `oxy_rt_<authuser>` slot, so we establish it here through the shared
|
|
152
|
+
// `POST /auth/session` primitive. No-op on native and cross-apex (returns
|
|
153
|
+
// `null`); records the active slot when one is genuinely allocated.
|
|
154
|
+
const verifyAuthuser = await oxyServices.establishDeviceRefreshSlot();
|
|
155
|
+
if (typeof verifyAuthuser === 'number') {
|
|
156
|
+
writeActiveAuthuser(verifyAuthuser);
|
|
157
|
+
}
|
|
158
|
+
|
|
136
159
|
// Get full user data
|
|
137
160
|
fullUser = await oxyServices.getUserBySession(sessionResponse.sessionId);
|
|
138
161
|
|
|
@@ -260,7 +283,14 @@ export const useAuthOperations = ({
|
|
|
260
283
|
await switchSession(filteredSessions[0].sessionId);
|
|
261
284
|
} else {
|
|
262
285
|
// Genuine FULL sign-out (no sessions remain): clear the per-origin
|
|
263
|
-
// SSO bounce state so a fresh deliberate sign-in can re-probe
|
|
286
|
+
// SSO bounce state so a fresh deliberate sign-in can re-probe, drop
|
|
287
|
+
// the persisted active device slot so the next cold boot does not
|
|
288
|
+
// prioritize a now-signed-out `oxy_active_authuser`, and SET the
|
|
289
|
+
// deliberately-signed-out flag so the silent cold-boot steps
|
|
290
|
+
// (`fedcm-silent` / per-apex iframe) do not re-mint a session from a
|
|
291
|
+
// still-live IdP session on the next reload (mirrors `logoutAll`).
|
|
292
|
+
markSignedOut();
|
|
293
|
+
clearActiveAuthuser();
|
|
264
294
|
clearSsoBounceState();
|
|
265
295
|
clearPriorSessionHintSafe(clearPriorSessionHint, logger);
|
|
266
296
|
await clearSessionState();
|
|
@@ -322,6 +352,9 @@ export const useAuthOperations = ({
|
|
|
322
352
|
if (isWebBrowser()) {
|
|
323
353
|
await oxyServices.logoutAllSessionsViaCookie();
|
|
324
354
|
clearActiveAuthuser();
|
|
355
|
+
// Deliberate full sign-out: suppress automatic silent restore on the next
|
|
356
|
+
// cold boot so a still-live IdP session does not re-mint a session.
|
|
357
|
+
markSignedOut();
|
|
325
358
|
}
|
|
326
359
|
// logoutAll is ALWAYS a full sign-out: clear the per-origin SSO bounce
|
|
327
360
|
// state (web-guarded internally) so a fresh sign-in can re-probe, and drop
|
package/src/ui/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export { FontLoader, setupFonts } from './components/FontLoader';
|
|
|
27
27
|
export { default as OxyIcon } from './components/icon/OxyIcon';
|
|
28
28
|
export { default as AccountMenu } from './components/AccountMenu';
|
|
29
29
|
export { default as AccountMenuButton } from './components/AccountMenuButton';
|
|
30
|
+
export { default as ProfileButton } from './components/ProfileButton';
|
|
31
|
+
export { default as ProfileMenu } from './components/ProfileMenu';
|
|
30
32
|
export { default as AccountSwitcher, AccountSwitcherView } from './components/AccountSwitcher';
|
|
31
33
|
|
|
32
34
|
// Context + hooks
|