@oxy.so/services 4.0.1 → 4.0.3
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/ui/components/OxyAccountDialogScreen.js +10 -16
- package/lib/commonjs/ui/components/OxyAccountDialogScreen.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAuthChooser.js +8 -2
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/commonjs/ui/components/OxyConsentScreen.js +2 -1
- package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -1
- package/lib/commonjs/ui/components/OxySignInRequestSurface.js +1 -0
- package/lib/commonjs/ui/components/OxySignInRequestSurface.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/SignInEntryView.js +5 -1
- package/lib/commonjs/ui/components/authChooser/SignInEntryView.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/SignUpView.js +2 -0
- package/lib/commonjs/ui/components/authChooser/SignUpView.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/TroubleDisclosure.js +2 -1
- package/lib/commonjs/ui/components/authChooser/TroubleDisclosure.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/requestSurfaces.js +1 -0
- package/lib/commonjs/ui/components/authChooser/requestSurfaces.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/styles.js +13 -3
- package/lib/commonjs/ui/components/authChooser/styles.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +52 -20
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/accountDialogSnapshot.js +1 -0
- package/lib/commonjs/ui/hooks/accountDialogSnapshot.js.map +1 -1
- package/lib/commonjs/ui/icons/Ionicons.js +3 -1
- package/lib/commonjs/ui/icons/Ionicons.js.map +1 -1
- package/lib/commonjs/ui/icons/MaterialCommunityIcons.js +3 -1
- package/lib/commonjs/ui/icons/MaterialCommunityIcons.js.map +1 -1
- package/lib/commonjs/ui/icons/decorativeIconSet.js +50 -0
- package/lib/commonjs/ui/icons/decorativeIconSet.js.map +1 -0
- package/lib/commonjs/ui/screens/ManageAccountScreen.js +10 -7
- package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/commonjs/ui/session/tokenLossRecovery.js +122 -0
- package/lib/commonjs/ui/session/tokenLossRecovery.js.map +1 -0
- package/lib/commonjs/ui/utils/deviceSessionTitle.js +30 -0
- package/lib/commonjs/ui/utils/deviceSessionTitle.js.map +1 -0
- package/lib/module/ui/components/OxyAccountDialogScreen.js +10 -16
- package/lib/module/ui/components/OxyAccountDialogScreen.js.map +1 -1
- package/lib/module/ui/components/OxyAuthChooser.js +8 -2
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/module/ui/components/OxyConsentScreen.js +2 -1
- package/lib/module/ui/components/OxyConsentScreen.js.map +1 -1
- package/lib/module/ui/components/OxySignInRequestSurface.js +1 -0
- package/lib/module/ui/components/OxySignInRequestSurface.js.map +1 -1
- package/lib/module/ui/components/authChooser/SignInEntryView.js +5 -1
- package/lib/module/ui/components/authChooser/SignInEntryView.js.map +1 -1
- package/lib/module/ui/components/authChooser/SignUpView.js +2 -0
- package/lib/module/ui/components/authChooser/SignUpView.js.map +1 -1
- package/lib/module/ui/components/authChooser/TroubleDisclosure.js +2 -1
- package/lib/module/ui/components/authChooser/TroubleDisclosure.js.map +1 -1
- package/lib/module/ui/components/authChooser/requestSurfaces.js +1 -0
- package/lib/module/ui/components/authChooser/requestSurfaces.js.map +1 -1
- package/lib/module/ui/components/authChooser/styles.js +13 -3
- package/lib/module/ui/components/authChooser/styles.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +53 -21
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/hooks/accountDialogSnapshot.js +1 -0
- package/lib/module/ui/hooks/accountDialogSnapshot.js.map +1 -1
- package/lib/module/ui/icons/Ionicons.js +4 -1
- package/lib/module/ui/icons/Ionicons.js.map +1 -1
- package/lib/module/ui/icons/MaterialCommunityIcons.js +4 -1
- package/lib/module/ui/icons/MaterialCommunityIcons.js.map +1 -1
- package/lib/module/ui/icons/decorativeIconSet.js +47 -0
- package/lib/module/ui/icons/decorativeIconSet.js.map +1 -0
- package/lib/module/ui/screens/ManageAccountScreen.js +10 -7
- package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/module/ui/session/tokenLossRecovery.js +116 -0
- package/lib/module/ui/session/tokenLossRecovery.js.map +1 -0
- package/lib/module/ui/utils/deviceSessionTitle.js +26 -0
- package/lib/module/ui/utils/deviceSessionTitle.js.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyAccountDialogScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +10 -1
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/TroubleDisclosure.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/styles.d.ts +12 -2
- package/lib/typescript/commonjs/ui/components/authChooser/styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +2 -1
- package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +2 -1
- package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/icons/decorativeIconSet.d.ts +26 -0
- package/lib/typescript/commonjs/ui/icons/decorativeIconSet.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/tokenLossRecovery.d.ts +37 -0
- package/lib/typescript/commonjs/ui/session/tokenLossRecovery.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/deviceSessionTitle.d.ts +18 -0
- package/lib/typescript/commonjs/ui/utils/deviceSessionTitle.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyAccountDialogScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +10 -1
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/TroubleDisclosure.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/styles.d.ts +12 -2
- package/lib/typescript/module/ui/components/authChooser/styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
- package/lib/typescript/module/ui/icons/Ionicons.d.ts +2 -1
- package/lib/typescript/module/ui/icons/Ionicons.d.ts.map +1 -1
- package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +2 -1
- package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts.map +1 -1
- package/lib/typescript/module/ui/icons/decorativeIconSet.d.ts +26 -0
- package/lib/typescript/module/ui/icons/decorativeIconSet.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/tokenLossRecovery.d.ts +37 -0
- package/lib/typescript/module/ui/session/tokenLossRecovery.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/deviceSessionTitle.d.ts +18 -0
- package/lib/typescript/module/ui/utils/deviceSessionTitle.d.ts.map +1 -0
- package/package.json +6 -6
- package/src/ui/components/OxyAccountDialogScreen.tsx +10 -16
- package/src/ui/components/OxyAuthChooser.tsx +8 -2
- package/src/ui/components/OxyConsentScreen.tsx +11 -1
- package/src/ui/components/OxySignInRequestSurface.tsx +1 -1
- package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +16 -0
- package/src/ui/components/authChooser/SignInEntryView.tsx +3 -0
- package/src/ui/components/authChooser/SignUpView.tsx +2 -2
- package/src/ui/components/authChooser/TroubleDisclosure.tsx +5 -2
- package/src/ui/components/authChooser/requestSurfaces.tsx +1 -1
- package/src/ui/components/authChooser/styles.ts +12 -2
- package/src/ui/context/OxyContext.tsx +53 -23
- package/src/ui/hooks/accountDialogSnapshot.ts +1 -0
- package/src/ui/icons/Ionicons.ts +9 -4
- package/src/ui/icons/MaterialCommunityIcons.ts +9 -4
- package/src/ui/icons/__tests__/decorativeIconSet.test.tsx +40 -0
- package/src/ui/icons/decorativeIconSet.ts +41 -0
- package/src/ui/screens/ManageAccountScreen.tsx +13 -18
- package/src/ui/session/__tests__/tokenLossRecovery.test.ts +195 -0
- package/src/ui/session/tokenLossRecovery.ts +146 -0
- package/src/ui/utils/__tests__/deviceSessionTitle.test.ts +31 -0
- package/src/ui/utils/deviceSessionTitle.ts +29 -0
package/src/ui/icons/Ionicons.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import createIconSet from '@expo/vector-icons/createIconSet';
|
|
2
|
+
import { decorativeIconSet } from './decorativeIconSet';
|
|
2
3
|
import font from '../../assets/fonts/icons/OxyServicesIonicons.ttf';
|
|
3
4
|
import { ioniconsGlyphMap } from './subsetGlyphMaps';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/** Glyphs are decorative: hidden from assistive technology (see `decorativeIconSet`). */
|
|
7
|
+
const Ionicons = decorativeIconSet(
|
|
8
|
+
createIconSet(
|
|
9
|
+
ioniconsGlyphMap,
|
|
10
|
+
'OxyServicesIonicons',
|
|
11
|
+
font,
|
|
12
|
+
),
|
|
13
|
+
'Ionicons',
|
|
9
14
|
);
|
|
10
15
|
|
|
11
16
|
export default Ionicons;
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import createIconSet from '@expo/vector-icons/createIconSet';
|
|
2
|
+
import { decorativeIconSet } from './decorativeIconSet';
|
|
2
3
|
import font from '../../assets/fonts/icons/OxyServicesMaterialCommunityIcons.ttf';
|
|
3
4
|
import { materialCommunityIconsGlyphMap } from './subsetGlyphMaps';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/** Glyphs are decorative: hidden from assistive technology (see `decorativeIconSet`). */
|
|
7
|
+
const MaterialCommunityIcons = decorativeIconSet(
|
|
8
|
+
createIconSet(
|
|
9
|
+
materialCommunityIconsGlyphMap,
|
|
10
|
+
'OxyServicesMaterialCommunityIcons',
|
|
11
|
+
font,
|
|
12
|
+
),
|
|
13
|
+
'MaterialCommunityIcons',
|
|
9
14
|
);
|
|
10
15
|
|
|
11
16
|
export default MaterialCommunityIcons;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon-font glyphs are private-use code points in a `Text` node; unhidden, a
|
|
3
|
+
* screen reader announces them (`"\u{F0140}"` beside "Switch account" in the
|
|
4
|
+
* Android account menu, OxyHQ/oxy#1375). Both SDK icon families hide every
|
|
5
|
+
* glyph, and a caller cannot switch that back on.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { render } from '@testing-library/react';
|
|
9
|
+
import Ionicons from '../Ionicons';
|
|
10
|
+
import MaterialCommunityIcons from '../MaterialCommunityIcons';
|
|
11
|
+
import { decorativeIconSet } from '../decorativeIconSet';
|
|
12
|
+
|
|
13
|
+
describe('the SDK icon families are decorative', () => {
|
|
14
|
+
it.each([
|
|
15
|
+
['MaterialCommunityIcons', MaterialCommunityIcons, 'chevron-down'],
|
|
16
|
+
['Ionicons', Ionicons, 'add'],
|
|
17
|
+
] as const)('%s hides its glyph from assistive technology', (_family, Icon, name) => {
|
|
18
|
+
const { container } = render(<Icon name={name as never} size={20} />);
|
|
19
|
+
|
|
20
|
+
expect(container.querySelector(`[data-icon="${name}"]`)?.getAttribute('aria-hidden')).toBe('true');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps the glyph hidden even when a caller asks otherwise', () => {
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<MaterialCommunityIcons name="chevron-down" aria-hidden={false} />,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(container.querySelector('[data-icon]')?.getAttribute('aria-hidden')).toBe('true');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('carries the family statics, so it stays a drop-in for the family', () => {
|
|
32
|
+
expect(MaterialCommunityIcons.glyphMap['chevron-down']).toEqual(expect.any(Number));
|
|
33
|
+
expect(Ionicons.glyphMap.add).toEqual(expect.any(Number));
|
|
34
|
+
|
|
35
|
+
const Family = Object.assign(() => null, { glyphMap: { a: 1 }, getFontFamily: () => 'F' });
|
|
36
|
+
const Wrapped = decorativeIconSet(Family, 'Wrapped');
|
|
37
|
+
expect(Wrapped.glyphMap).toBe(Family.glyphMap);
|
|
38
|
+
expect(Wrapped.getFontFamily()).toBe('F');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createElement, type ComponentProps, type ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Function built-ins and statics a function component must not inherit. */
|
|
4
|
+
const NOT_COPIED = new Set(['length', 'name', 'prototype', 'caller', 'arguments', 'defaultProps', 'displayName']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrap an icon-font family so every glyph it renders is hidden from assistive
|
|
8
|
+
* technology.
|
|
9
|
+
*
|
|
10
|
+
* A vector-icons glyph is a `Text` node whose content is a private-use code
|
|
11
|
+
* point. A screen reader reads it like any other text: Android QA of the account
|
|
12
|
+
* menu found `"\u{F0140}"` announced next to "Switch account" (OxyHQ/oxy#1375),
|
|
13
|
+
* and on Android the code point is folded into the label of the pressable around
|
|
14
|
+
* it, because RN builds an unlabelled accessible view's description from its
|
|
15
|
+
* children's text.
|
|
16
|
+
*
|
|
17
|
+
* Every glyph in this SDK is decorative — the control or row around it carries
|
|
18
|
+
* the words — so it is hidden here, once, rather than at each call site where
|
|
19
|
+
* the next icon would forget it. `aria-hidden` is
|
|
20
|
+
* `importantForAccessibility="no-hide-descendants"` on Android,
|
|
21
|
+
* `accessibilityElementsHidden` on iOS and `aria-hidden` on web. It is applied
|
|
22
|
+
* AFTER the caller's props so it cannot be switched back on; a glyph that needs
|
|
23
|
+
* a name belongs inside a control that has one. (Mention's own wrapper,
|
|
24
|
+
* `components/common/Ionicons.tsx`, is the same rule.)
|
|
25
|
+
*
|
|
26
|
+
* The family's statics (`glyphMap`, `getImageSource`, `loadFont`, …) are carried
|
|
27
|
+
* over, so the wrapper is a drop-in for the family itself.
|
|
28
|
+
*/
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: an icon family's own props are whatever `createIconSet` typed them as; the wrapper returns the SAME type.
|
|
30
|
+
export function decorativeIconSet<T extends ComponentType<any>>(IconSet: T, displayName: string): T {
|
|
31
|
+
function DecorativeIcon(props: ComponentProps<T>) {
|
|
32
|
+
return createElement(IconSet, { ...props, 'aria-hidden': true });
|
|
33
|
+
}
|
|
34
|
+
for (const key of Object.getOwnPropertyNames(IconSet)) {
|
|
35
|
+
if (NOT_COPIED.has(key)) continue;
|
|
36
|
+
const descriptor = Object.getOwnPropertyDescriptor(IconSet, key);
|
|
37
|
+
if (descriptor) Object.defineProperty(DecorativeIcon, key, descriptor);
|
|
38
|
+
}
|
|
39
|
+
DecorativeIcon.displayName = displayName;
|
|
40
|
+
return DecorativeIcon as unknown as T;
|
|
41
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getNormalizedUserHandle,
|
|
21
21
|
logger as loggerUtil,
|
|
22
22
|
packageInfo,
|
|
23
|
+
type DeviceLinkedSession,
|
|
23
24
|
} from '@oxy.so/core';
|
|
24
25
|
import type { BaseScreenProps } from '../types/navigation';
|
|
25
26
|
import ProfileSummaryCard from '../components/ProfileSummaryCard';
|
|
@@ -33,16 +34,9 @@ import { isWebBrowser } from '../utils/isWebBrowser';
|
|
|
33
34
|
import { useCurrentUser } from '../hooks/queries/useAccountQueries';
|
|
34
35
|
import { useUserSubscription } from '../hooks/queries/usePaymentQueries';
|
|
35
36
|
import { useDeviceSessions } from '../hooks/queries/useServicesQueries';
|
|
37
|
+
import { deviceSessionTitle } from '../utils/deviceSessionTitle';
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
sessionId: string;
|
|
39
|
-
deviceId: string;
|
|
40
|
-
deviceName: string;
|
|
41
|
-
isActive: boolean;
|
|
42
|
-
lastActive: string;
|
|
43
|
-
expiresAt: string;
|
|
44
|
-
isCurrent: boolean;
|
|
45
|
-
}
|
|
39
|
+
type DeviceSessionRow = DeviceLinkedSession;
|
|
46
40
|
|
|
47
41
|
const AVATAR_SIZE = 88;
|
|
48
42
|
|
|
@@ -172,8 +166,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
172
166
|
const confirmed = await surfaces.confirm({
|
|
173
167
|
title: t('manageAccount.confirms.removeDeviceTitle') || 'Remove device',
|
|
174
168
|
description:
|
|
175
|
-
t('manageAccount.confirms.removeDevice', { name: device
|
|
176
|
-
|| `Sign out from "${device.deviceName}"?`,
|
|
169
|
+
t('manageAccount.confirms.removeDevice', { name: deviceSessionTitle(device, t) }),
|
|
177
170
|
confirmLabel: t('common.remove') || 'Remove',
|
|
178
171
|
cancelLabel: t('common.cancel') || 'Cancel',
|
|
179
172
|
destructive: true,
|
|
@@ -186,8 +179,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
186
179
|
await oxyServices.logoutSession(activeSessionId, device.sessionId);
|
|
187
180
|
await refetchDeviceSessions();
|
|
188
181
|
toast.success(
|
|
189
|
-
t('manageAccount.toasts.deviceRemoved', { name: device
|
|
190
|
-
|| `Signed out from ${device.deviceName}`,
|
|
182
|
+
t('manageAccount.toasts.deviceRemoved', { name: deviceSessionTitle(device, t) }),
|
|
191
183
|
);
|
|
192
184
|
} catch (error) {
|
|
193
185
|
loggerUtil.warn('Remove device failed', { component: 'ManageAccountScreen' }, error as unknown);
|
|
@@ -503,7 +495,7 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
503
495
|
}
|
|
504
496
|
/>
|
|
505
497
|
}
|
|
506
|
-
title={
|
|
498
|
+
title={deviceSessionTitle(device, t)}
|
|
507
499
|
description={
|
|
508
500
|
t('manageAccount.sessions.lastActive', {
|
|
509
501
|
relative: formatRelative(device.lastActive),
|
|
@@ -711,10 +703,13 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
711
703
|
description={
|
|
712
704
|
accounts.length > 0
|
|
713
705
|
? (
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
706
|
+
// One account is "1 account", not "1 accounts".
|
|
707
|
+
t(
|
|
708
|
+
accounts.length === 1
|
|
709
|
+
? 'accounts.manage.switch.countOne'
|
|
710
|
+
: 'accounts.manage.switch.count',
|
|
711
|
+
{ count: accounts.length },
|
|
712
|
+
)
|
|
718
713
|
)
|
|
719
714
|
: (
|
|
720
715
|
t('accounts.manage.switch.empty')
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lost access token is a question, not a sign-out (OxyHQ/Mention#1140).
|
|
3
|
+
*
|
|
4
|
+
* The provider used to sign the user out on ANY cleared bearer. HttpService
|
|
5
|
+
* clears it whenever a 401's refresh comes back empty — including when the mint
|
|
6
|
+
* is merely cooling down after a 429 or the network blipped — and with no token
|
|
7
|
+
* the refresh scheduler stops, so the session never came back until a relaunch
|
|
8
|
+
* re-minted from the device credential that had been intact the whole time.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
createTokenLossRecovery,
|
|
12
|
+
MAX_KEYED_RECOVERY_ATTEMPTS,
|
|
13
|
+
TOKEN_RECOVERY_BACKOFF_MS,
|
|
14
|
+
type TokenLossRecoveryDeps,
|
|
15
|
+
} from '../tokenLossRecovery';
|
|
16
|
+
|
|
17
|
+
interface Harness {
|
|
18
|
+
deps: TokenLossRecoveryDeps;
|
|
19
|
+
state: { signedIn: boolean; token: string | null; credential: boolean; keyed: boolean };
|
|
20
|
+
remint: jest.Mock<Promise<string | null>, []>;
|
|
21
|
+
signOutLocally: jest.Mock<Promise<void>, []>;
|
|
22
|
+
sleeps: number[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function harness(remintAnswers: Array<string | null | 'drop-credential'>): Harness {
|
|
26
|
+
const state = { signedIn: true, token: null as string | null, credential: true, keyed: false };
|
|
27
|
+
const sleeps: number[] = [];
|
|
28
|
+
const remint = jest.fn(async (): Promise<string | null> => {
|
|
29
|
+
const answer = remintAnswers.length > 0 ? remintAnswers.shift() : null;
|
|
30
|
+
if (answer === 'drop-credential') {
|
|
31
|
+
// What the refresh handler does on `invalid_device_secret` /
|
|
32
|
+
// `no_active_session`: the credential goes, and the mint returns nothing.
|
|
33
|
+
state.credential = false;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (answer) {
|
|
37
|
+
state.token = answer;
|
|
38
|
+
}
|
|
39
|
+
return answer ?? null;
|
|
40
|
+
});
|
|
41
|
+
const signOutLocally = jest.fn(async () => {
|
|
42
|
+
state.signedIn = false;
|
|
43
|
+
});
|
|
44
|
+
const deps: TokenLossRecoveryDeps = {
|
|
45
|
+
remint,
|
|
46
|
+
hasDeviceCredential: async () => state.credential,
|
|
47
|
+
hasKeyedRecovery: async () => state.keyed,
|
|
48
|
+
isSignedIn: () => state.signedIn,
|
|
49
|
+
hasToken: () => state.token !== null,
|
|
50
|
+
signOutLocally,
|
|
51
|
+
sleep: async (ms) => {
|
|
52
|
+
sleeps.push(ms);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return { deps, state, remint, signOutLocally, sleeps };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runToCompletion(recovery: ReturnType<typeof createTokenLossRecovery>): Promise<void> {
|
|
59
|
+
for (let i = 0; i < 200 && recovery.isRecovering(); i += 1) {
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
61
|
+
}
|
|
62
|
+
expect(recovery.isRecovering()).toBe(false);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('token loss recovery', () => {
|
|
66
|
+
it('keeps a user with an intact device credential signed in through transient failures, then restores the token', async () => {
|
|
67
|
+
// The incident: the mint is rate limited / cooling down for a while, then
|
|
68
|
+
// succeeds — exactly what a relaunch a few minutes later did.
|
|
69
|
+
const h = harness([null, null, null, 'fresh']);
|
|
70
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
71
|
+
|
|
72
|
+
recovery.start();
|
|
73
|
+
await runToCompletion(recovery);
|
|
74
|
+
|
|
75
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
76
|
+
expect(h.state.token).toBe('fresh');
|
|
77
|
+
expect(h.remint).toHaveBeenCalledTimes(4);
|
|
78
|
+
expect(h.sleeps).toEqual(TOKEN_RECOVERY_BACKOFF_MS.slice(0, 3));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('signs out once a re-mint gets a definitive verdict and no key can re-establish the session', async () => {
|
|
82
|
+
const h = harness([null, 'drop-credential']);
|
|
83
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
84
|
+
|
|
85
|
+
recovery.start();
|
|
86
|
+
await runToCompletion(recovery);
|
|
87
|
+
|
|
88
|
+
expect(h.signOutLocally).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(h.remint).toHaveBeenCalledTimes(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('signs out at once when the credential was already gone and there is no key-based lane (the old behaviour for a real revocation)', async () => {
|
|
93
|
+
const h = harness([]);
|
|
94
|
+
h.state.credential = false;
|
|
95
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
96
|
+
|
|
97
|
+
recovery.start();
|
|
98
|
+
await runToCompletion(recovery);
|
|
99
|
+
|
|
100
|
+
expect(h.remint).not.toHaveBeenCalled();
|
|
101
|
+
expect(h.signOutLocally).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('re-establishes through the shared identity when the credential is gone, like the cold boot does', async () => {
|
|
105
|
+
const h = harness([null, 'from-shared-key']);
|
|
106
|
+
h.state.credential = false;
|
|
107
|
+
h.state.keyed = true;
|
|
108
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
109
|
+
|
|
110
|
+
recovery.start();
|
|
111
|
+
await runToCompletion(recovery);
|
|
112
|
+
|
|
113
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
114
|
+
expect(h.state.token).toBe('from-shared-key');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('gives the key-based lane a bounded number of tries, then signs out', async () => {
|
|
118
|
+
const h = harness([]);
|
|
119
|
+
h.state.credential = false;
|
|
120
|
+
h.state.keyed = true;
|
|
121
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
122
|
+
|
|
123
|
+
recovery.start();
|
|
124
|
+
await runToCompletion(recovery);
|
|
125
|
+
|
|
126
|
+
expect(h.remint).toHaveBeenCalledTimes(MAX_KEYED_RECOVERY_ATTEMPTS);
|
|
127
|
+
expect(h.signOutLocally).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('stops without touching the session when the user signs out meanwhile', async () => {
|
|
131
|
+
const h = harness([]);
|
|
132
|
+
h.remint.mockImplementation(async () => {
|
|
133
|
+
h.state.signedIn = false;
|
|
134
|
+
return null;
|
|
135
|
+
});
|
|
136
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
137
|
+
|
|
138
|
+
recovery.start();
|
|
139
|
+
await runToCompletion(recovery);
|
|
140
|
+
|
|
141
|
+
expect(h.remint).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('stops when another lane planted a token first', async () => {
|
|
146
|
+
const h = harness([]);
|
|
147
|
+
h.remint.mockImplementation(async () => {
|
|
148
|
+
h.state.token = 'planted-by-a-sign-in';
|
|
149
|
+
return null;
|
|
150
|
+
});
|
|
151
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
152
|
+
|
|
153
|
+
recovery.start();
|
|
154
|
+
await runToCompletion(recovery);
|
|
155
|
+
|
|
156
|
+
expect(h.remint).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('runs one recovery at a time', async () => {
|
|
161
|
+
const h = harness([null, 'fresh']);
|
|
162
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
163
|
+
|
|
164
|
+
recovery.start();
|
|
165
|
+
recovery.start();
|
|
166
|
+
await runToCompletion(recovery);
|
|
167
|
+
|
|
168
|
+
expect(h.remint).toHaveBeenCalledTimes(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('signs out rather than strand a signed-in UI with no bearer when recovery itself throws', async () => {
|
|
172
|
+
const h = harness([]);
|
|
173
|
+
h.deps.hasDeviceCredential = async () => {
|
|
174
|
+
throw new Error('store exploded');
|
|
175
|
+
};
|
|
176
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
177
|
+
|
|
178
|
+
recovery.start();
|
|
179
|
+
await runToCompletion(recovery);
|
|
180
|
+
|
|
181
|
+
expect(h.signOutLocally).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('does nothing after dispose', async () => {
|
|
185
|
+
const h = harness([]);
|
|
186
|
+
const recovery = createTokenLossRecovery(h.deps);
|
|
187
|
+
|
|
188
|
+
recovery.dispose();
|
|
189
|
+
recovery.start();
|
|
190
|
+
await runToCompletion(recovery);
|
|
191
|
+
|
|
192
|
+
expect(h.remint).not.toHaveBeenCalled();
|
|
193
|
+
expect(h.signOutLocally).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a signed-in app does when its access token disappears at runtime.
|
|
3
|
+
*
|
|
4
|
+
* `HttpService` clears the bearer when a request draws a 401 and the refresh it
|
|
5
|
+
* then tries returns nothing. That refresh can return nothing for reasons that
|
|
6
|
+
* say NOTHING about the session: the mint is cooling down after a failure or a
|
|
7
|
+
* 429, the network dropped, the server was restarting. The provider used to read
|
|
8
|
+
* every such clear as a sign-out and tore the session down on the spot — and
|
|
9
|
+
* because the proactive refresh scheduler only runs while a token exists, nothing
|
|
10
|
+
* was left to bring the session back. Only a relaunch did, because the cold boot
|
|
11
|
+
* mints from the durable device credential that had been there all along
|
|
12
|
+
* (OxyHQ/Mention#1140).
|
|
13
|
+
*
|
|
14
|
+
* So a lost token is a question, and this answers it the way a relaunch would:
|
|
15
|
+
*
|
|
16
|
+
* - The durable store still holds the device credential → the session is NOT
|
|
17
|
+
* known to be over. Keep the user signed in (private queries pause, because
|
|
18
|
+
* there is no bearer) and re-mint with backoff until one succeeds. A
|
|
19
|
+
* definitive server verdict (`invalid_device_secret`, `no_active_session`)
|
|
20
|
+
* makes the refresh handler drop or clear that credential, which is what ends
|
|
21
|
+
* this loop in a sign-out — so a real revocation still signs out.
|
|
22
|
+
* - No credential, but a key-based lane can still re-establish (the native
|
|
23
|
+
* shared identity, or an identity-bound client's own key) → try that a few
|
|
24
|
+
* times, as the cold boot's shared-key step would, then sign out.
|
|
25
|
+
* - Neither → the session is over. Sign out now, as before.
|
|
26
|
+
*
|
|
27
|
+
* Framework-free and clock-injectable so the policy is unit tested without a
|
|
28
|
+
* provider.
|
|
29
|
+
*/
|
|
30
|
+
import { logger } from '@oxy.so/core';
|
|
31
|
+
|
|
32
|
+
export interface TokenLossRecoveryDeps {
|
|
33
|
+
/** Re-mint once through the installed refresh handler; the token, or null. */
|
|
34
|
+
remint: () => Promise<string | null>;
|
|
35
|
+
/** Whether the durable auth store still holds `deviceId` + `deviceSecret`. */
|
|
36
|
+
hasDeviceCredential: () => Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a key-based lane could still re-establish the session without a
|
|
39
|
+
* device secret. Consulted only once the credential is gone.
|
|
40
|
+
*/
|
|
41
|
+
hasKeyedRecovery: () => Promise<boolean>;
|
|
42
|
+
/** Whether the UI still shows a signed-in user. */
|
|
43
|
+
isSignedIn: () => boolean;
|
|
44
|
+
/** Whether a bearer is planted right now. */
|
|
45
|
+
hasToken: () => boolean;
|
|
46
|
+
/** Tear down the local session (the provider's `clearSessionState`). */
|
|
47
|
+
signOutLocally: () => Promise<void>;
|
|
48
|
+
/** Timer seam for tests. */
|
|
49
|
+
sleep?: (ms: number) => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TokenLossRecovery {
|
|
53
|
+
/** Start recovering, unless a recovery is already running. */
|
|
54
|
+
start(): void;
|
|
55
|
+
/** Abandon any running recovery (provider unmount). */
|
|
56
|
+
dispose(): void;
|
|
57
|
+
/** Whether a recovery is running. */
|
|
58
|
+
isRecovering(): boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Delays between re-mint attempts while the device credential is intact. Starts
|
|
63
|
+
* near the post-failure refresh cooldowns and settles at one attempt a minute,
|
|
64
|
+
* which also respects the mint's own 60s rate-limit cooldown.
|
|
65
|
+
*/
|
|
66
|
+
export const TOKEN_RECOVERY_BACKOFF_MS: readonly number[] = [2_000, 5_000, 10_000, 20_000, 30_000, 60_000];
|
|
67
|
+
|
|
68
|
+
/** Attempts through a key-based lane once the device credential is gone. */
|
|
69
|
+
export const MAX_KEYED_RECOVERY_ATTEMPTS = 3;
|
|
70
|
+
|
|
71
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
72
|
+
new Promise((resolve) => {
|
|
73
|
+
setTimeout(resolve, ms);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const LOG_CONTEXT = { component: 'OxyContext', method: 'tokenLossRecovery' } as const;
|
|
77
|
+
|
|
78
|
+
export function createTokenLossRecovery(deps: TokenLossRecoveryDeps): TokenLossRecovery {
|
|
79
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
80
|
+
let running = false;
|
|
81
|
+
let disposed = false;
|
|
82
|
+
|
|
83
|
+
const settled = (): boolean => disposed || deps.hasToken() || !deps.isSignedIn();
|
|
84
|
+
|
|
85
|
+
const run = async (): Promise<void> => {
|
|
86
|
+
let attempt = 0;
|
|
87
|
+
let keyedAttempts = 0;
|
|
88
|
+
logger.warn('Access token lost while signed in — recovering the session instead of signing out', LOG_CONTEXT);
|
|
89
|
+
while (!settled()) {
|
|
90
|
+
if (!(await deps.hasDeviceCredential())) {
|
|
91
|
+
const keyed = keyedAttempts < MAX_KEYED_RECOVERY_ATTEMPTS && (await deps.hasKeyedRecovery());
|
|
92
|
+
if (!keyed) {
|
|
93
|
+
if (settled()) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
logger.warn('Session recovery ended: no device credential and no key-based lane left — signing out', {
|
|
97
|
+
...LOG_CONTEXT,
|
|
98
|
+
attempts: attempt,
|
|
99
|
+
keyedAttempts,
|
|
100
|
+
});
|
|
101
|
+
await deps.signOutLocally();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
keyedAttempts += 1;
|
|
105
|
+
}
|
|
106
|
+
if (settled()) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const token = await deps.remint().catch(() => null);
|
|
110
|
+
attempt += 1;
|
|
111
|
+
if (token || settled()) {
|
|
112
|
+
if (token) {
|
|
113
|
+
logger.warn('Session recovered after a lost access token', { ...LOG_CONTEXT, attempts: attempt });
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await sleep(TOKEN_RECOVERY_BACKOFF_MS[Math.min(attempt - 1, TOKEN_RECOVERY_BACKOFF_MS.length - 1)]);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
start(): void {
|
|
123
|
+
if (running || disposed) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
running = true;
|
|
127
|
+
void run()
|
|
128
|
+
.catch(async (error: unknown) => {
|
|
129
|
+
// Never leave a signed-in UI with no bearer and nothing recovering it.
|
|
130
|
+
logger.warn('Session recovery failed unexpectedly — signing out', LOG_CONTEXT, error);
|
|
131
|
+
if (!settled()) {
|
|
132
|
+
await deps.signOutLocally().catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.finally(() => {
|
|
136
|
+
running = false;
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
dispose(): void {
|
|
140
|
+
disposed = true;
|
|
141
|
+
},
|
|
142
|
+
isRecovering(): boolean {
|
|
143
|
+
return running;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { User } from '@oxy.so/core';
|
|
2
|
+
import { deviceSessionTitle } from '../deviceSessionTitle';
|
|
3
|
+
|
|
4
|
+
const t = (key: string) =>
|
|
5
|
+
({
|
|
6
|
+
'manageAccount.sessions.thisDevice': 'This device',
|
|
7
|
+
'manageAccount.sessions.otherSession': 'Another session',
|
|
8
|
+
})[key] ?? key;
|
|
9
|
+
|
|
10
|
+
describe('deviceSessionTitle', () => {
|
|
11
|
+
it('never reads "undefined" when the server sends no device name (OxyHQ/oxy#1375 item 11)', () => {
|
|
12
|
+
// The live response shape: no `deviceName` key at all.
|
|
13
|
+
const title = deviceSessionTitle({ isCurrent: true } as never, t);
|
|
14
|
+
expect(title).toBe('This device');
|
|
15
|
+
expect(title).not.toContain('undefined');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("names another session by its account, which the response does carry", () => {
|
|
19
|
+
const user = { id: 'u2', username: 'bob', name: { displayName: 'Bob' } } as unknown as User;
|
|
20
|
+
expect(deviceSessionTitle({ isCurrent: false, user } as never, t)).toBe('Bob');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('falls back to a generic label when nothing names the session', () => {
|
|
24
|
+
expect(deviceSessionTitle({ isCurrent: false } as never, t)).toBe('Another session');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('keeps a real device name, marking the current one', () => {
|
|
28
|
+
expect(deviceSessionTitle({ deviceName: 'Pixel 8a', isCurrent: true }, t)).toBe('Pixel 8a (This device)');
|
|
29
|
+
expect(deviceSessionTitle({ deviceName: 'Pixel 8a', isCurrent: false }, t)).toBe('Pixel 8a');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getNormalizedUserHandle, type DeviceLinkedSession } from '@oxy.so/core';
|
|
2
|
+
|
|
3
|
+
type Translate = (key: string, vars?: Record<string, string | number>) => string;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The title of one row in "Sessions & devices".
|
|
7
|
+
*
|
|
8
|
+
* `GET /session/device/sessions/:sessionId` lists the sessions that share THIS
|
|
9
|
+
* physical device — one per account signed in here — and sends no
|
|
10
|
+
* `deviceName` at all, although the model declared one. The row interpolated
|
|
11
|
+
* it unconditionally and Android QA read "undefined (This device)"
|
|
12
|
+
* (OxyHQ/oxy#1375 item 11).
|
|
13
|
+
*
|
|
14
|
+
* A name the server does send is used; otherwise the current session is "This
|
|
15
|
+
* device" and any other is named by the account it belongs to, which the
|
|
16
|
+
* response does carry. Never the string "undefined".
|
|
17
|
+
*/
|
|
18
|
+
export function deviceSessionTitle(
|
|
19
|
+
session: Pick<DeviceLinkedSession, 'deviceName' | 'isCurrent' | 'user'>,
|
|
20
|
+
t: Translate,
|
|
21
|
+
): string {
|
|
22
|
+
const thisDevice = t('manageAccount.sessions.thisDevice');
|
|
23
|
+
const deviceName = typeof session.deviceName === 'string' ? session.deviceName.trim() : '';
|
|
24
|
+
if (deviceName) return session.isCurrent ? `${deviceName} (${thisDevice})` : deviceName;
|
|
25
|
+
if (session.isCurrent) return thisDevice;
|
|
26
|
+
const user = session.user;
|
|
27
|
+
const name = user?.name?.displayName?.trim() || (user ? getNormalizedUserHandle(user) : null);
|
|
28
|
+
return name || t('manageAccount.sessions.otherSession');
|
|
29
|
+
}
|