@oxy.so/services 12.3.0 → 12.4.0

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 (152) hide show
  1. package/README.md +1 -1
  2. package/lib/commonjs/index.js +17 -2
  3. package/lib/commonjs/index.js.map +1 -1
  4. package/lib/commonjs/ui/components/signIn/OxyAuthenticatorPanel.js +11 -10
  5. package/lib/commonjs/ui/components/signIn/OxyAuthenticatorPanel.js.map +1 -1
  6. package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js +19 -18
  7. package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -1
  8. package/lib/commonjs/ui/components/signIn/OxyEmailPanel.js +251 -0
  9. package/lib/commonjs/ui/components/signIn/OxyEmailPanel.js.map +1 -0
  10. package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js +40 -33
  11. package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -1
  12. package/lib/commonjs/ui/components/signIn/OxyPasswordPanel.js +51 -19
  13. package/lib/commonjs/ui/components/signIn/OxyPasswordPanel.js.map +1 -1
  14. package/lib/commonjs/ui/components/signIn/OxySignInSecurityPanel.js +154 -0
  15. package/lib/commonjs/ui/components/signIn/OxySignInSecurityPanel.js.map +1 -0
  16. package/lib/commonjs/ui/components/signIn/ReauthStep.js +6 -4
  17. package/lib/commonjs/ui/components/signIn/ReauthStep.js.map +1 -1
  18. package/lib/commonjs/ui/components/signIn/SessionsSection.js +200 -0
  19. package/lib/commonjs/ui/components/signIn/SessionsSection.js.map +1 -0
  20. package/lib/commonjs/ui/components/signIn/accountFlowParts.js +6 -0
  21. package/lib/commonjs/ui/components/signIn/accountFlowParts.js.map +1 -1
  22. package/lib/commonjs/ui/components/signIn/accountSecurityState.js +74 -0
  23. package/lib/commonjs/ui/components/signIn/accountSecurityState.js.map +1 -0
  24. package/lib/commonjs/ui/navigation/routes.js +1 -1
  25. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  26. package/lib/commonjs/ui/navigation/screenComponents.js +4 -0
  27. package/lib/commonjs/ui/navigation/screenComponents.js.map +1 -1
  28. package/lib/commonjs/ui/navigation/surfaceRegistry.js.map +1 -1
  29. package/lib/commonjs/ui/screens/AccountSecurityPanelScreen.js +1 -1
  30. package/lib/commonjs/ui/screens/ManageAccountScreen.js +8 -196
  31. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  32. package/lib/commonjs/ui/screens/SignInEmailScreen.js +19 -0
  33. package/lib/commonjs/ui/screens/SignInEmailScreen.js.map +1 -0
  34. package/lib/commonjs/ui/screens/SignInSecurityScreen.js +30 -0
  35. package/lib/commonjs/ui/screens/SignInSecurityScreen.js.map +1 -0
  36. package/lib/module/index.js +5 -2
  37. package/lib/module/index.js.map +1 -1
  38. package/lib/module/ui/components/signIn/OxyAuthenticatorPanel.js +12 -11
  39. package/lib/module/ui/components/signIn/OxyAuthenticatorPanel.js.map +1 -1
  40. package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js +20 -19
  41. package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -1
  42. package/lib/module/ui/components/signIn/OxyEmailPanel.js +246 -0
  43. package/lib/module/ui/components/signIn/OxyEmailPanel.js.map +1 -0
  44. package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js +40 -33
  45. package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -1
  46. package/lib/module/ui/components/signIn/OxyPasswordPanel.js +52 -20
  47. package/lib/module/ui/components/signIn/OxyPasswordPanel.js.map +1 -1
  48. package/lib/module/ui/components/signIn/OxySignInSecurityPanel.js +149 -0
  49. package/lib/module/ui/components/signIn/OxySignInSecurityPanel.js.map +1 -0
  50. package/lib/module/ui/components/signIn/ReauthStep.js +6 -4
  51. package/lib/module/ui/components/signIn/ReauthStep.js.map +1 -1
  52. package/lib/module/ui/components/signIn/SessionsSection.js +194 -0
  53. package/lib/module/ui/components/signIn/SessionsSection.js.map +1 -0
  54. package/lib/module/ui/components/signIn/accountFlowParts.js +6 -0
  55. package/lib/module/ui/components/signIn/accountFlowParts.js.map +1 -1
  56. package/lib/module/ui/components/signIn/accountSecurityState.js +69 -0
  57. package/lib/module/ui/components/signIn/accountSecurityState.js.map +1 -0
  58. package/lib/module/ui/navigation/routes.js +1 -1
  59. package/lib/module/ui/navigation/routes.js.map +1 -1
  60. package/lib/module/ui/navigation/screenComponents.js +4 -0
  61. package/lib/module/ui/navigation/screenComponents.js.map +1 -1
  62. package/lib/module/ui/navigation/surfaceRegistry.js.map +1 -1
  63. package/lib/module/ui/screens/AccountSecurityPanelScreen.js +1 -1
  64. package/lib/module/ui/screens/ManageAccountScreen.js +9 -197
  65. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  66. package/lib/module/ui/screens/SignInEmailScreen.js +17 -0
  67. package/lib/module/ui/screens/SignInEmailScreen.js.map +1 -0
  68. package/lib/module/ui/screens/SignInSecurityScreen.js +26 -0
  69. package/lib/module/ui/screens/SignInSecurityScreen.js.map +1 -0
  70. package/lib/typescript/commonjs/index.d.ts +4 -0
  71. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/components/signIn/OxyAuthenticatorPanel.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts +3 -2
  74. package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/ui/components/signIn/OxyEmailPanel.d.ts +21 -0
  76. package/lib/typescript/commonjs/ui/components/signIn/OxyEmailPanel.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts +2 -1
  78. package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/ui/components/signIn/OxyPasswordPanel.d.ts +5 -4
  80. package/lib/typescript/commonjs/ui/components/signIn/OxyPasswordPanel.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/ui/components/signIn/OxySignInSecurityPanel.d.ts +25 -0
  82. package/lib/typescript/commonjs/ui/components/signIn/OxySignInSecurityPanel.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/ui/components/signIn/ReauthStep.d.ts +8 -2
  84. package/lib/typescript/commonjs/ui/components/signIn/ReauthStep.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/ui/components/signIn/SessionsSection.d.ts +9 -0
  86. package/lib/typescript/commonjs/ui/components/signIn/SessionsSection.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/ui/components/signIn/accountSecurityState.d.ts +26 -0
  89. package/lib/typescript/commonjs/ui/components/signIn/accountSecurityState.d.ts.map +1 -0
  90. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
  91. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/navigation/screenComponents.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts +2 -0
  94. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/screens/AccountSecurityPanelScreen.d.ts +1 -1
  96. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts +3 -2
  97. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/screens/SignInEmailScreen.d.ts +6 -0
  99. package/lib/typescript/commonjs/ui/screens/SignInEmailScreen.d.ts.map +1 -0
  100. package/lib/typescript/commonjs/ui/screens/SignInSecurityScreen.d.ts +6 -0
  101. package/lib/typescript/commonjs/ui/screens/SignInSecurityScreen.d.ts.map +1 -0
  102. package/lib/typescript/module/index.d.ts +4 -0
  103. package/lib/typescript/module/index.d.ts.map +1 -1
  104. package/lib/typescript/module/ui/components/signIn/OxyAuthenticatorPanel.d.ts.map +1 -1
  105. package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts +3 -2
  106. package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -1
  107. package/lib/typescript/module/ui/components/signIn/OxyEmailPanel.d.ts +21 -0
  108. package/lib/typescript/module/ui/components/signIn/OxyEmailPanel.d.ts.map +1 -0
  109. package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts +2 -1
  110. package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -1
  111. package/lib/typescript/module/ui/components/signIn/OxyPasswordPanel.d.ts +5 -4
  112. package/lib/typescript/module/ui/components/signIn/OxyPasswordPanel.d.ts.map +1 -1
  113. package/lib/typescript/module/ui/components/signIn/OxySignInSecurityPanel.d.ts +25 -0
  114. package/lib/typescript/module/ui/components/signIn/OxySignInSecurityPanel.d.ts.map +1 -0
  115. package/lib/typescript/module/ui/components/signIn/ReauthStep.d.ts +8 -2
  116. package/lib/typescript/module/ui/components/signIn/ReauthStep.d.ts.map +1 -1
  117. package/lib/typescript/module/ui/components/signIn/SessionsSection.d.ts +9 -0
  118. package/lib/typescript/module/ui/components/signIn/SessionsSection.d.ts.map +1 -0
  119. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/components/signIn/accountSecurityState.d.ts +26 -0
  121. package/lib/typescript/module/ui/components/signIn/accountSecurityState.d.ts.map +1 -0
  122. package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
  123. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/navigation/screenComponents.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts +2 -0
  126. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/screens/AccountSecurityPanelScreen.d.ts +1 -1
  128. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts +3 -2
  129. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/screens/SignInEmailScreen.d.ts +6 -0
  131. package/lib/typescript/module/ui/screens/SignInEmailScreen.d.ts.map +1 -0
  132. package/lib/typescript/module/ui/screens/SignInSecurityScreen.d.ts +6 -0
  133. package/lib/typescript/module/ui/screens/SignInSecurityScreen.d.ts.map +1 -0
  134. package/package.json +5 -5
  135. package/src/index.ts +7 -2
  136. package/src/ui/components/signIn/OxyAuthenticatorPanel.tsx +11 -8
  137. package/src/ui/components/signIn/OxyDeleteAccountPanel.tsx +15 -16
  138. package/src/ui/components/signIn/OxyEmailPanel.tsx +258 -0
  139. package/src/ui/components/signIn/OxyLinkCommonsPanel.tsx +27 -15
  140. package/src/ui/components/signIn/OxyPasswordPanel.tsx +49 -14
  141. package/src/ui/components/signIn/OxySignInSecurityPanel.tsx +147 -0
  142. package/src/ui/components/signIn/ReauthStep.tsx +13 -4
  143. package/src/ui/components/signIn/SessionsSection.tsx +218 -0
  144. package/src/ui/components/signIn/accountFlowParts.tsx +6 -0
  145. package/src/ui/components/signIn/accountSecurityState.tsx +75 -0
  146. package/src/ui/navigation/routes.ts +5 -1
  147. package/src/ui/navigation/screenComponents.ts +4 -0
  148. package/src/ui/navigation/surfaceRegistry.ts +2 -0
  149. package/src/ui/screens/AccountSecurityPanelScreen.tsx +1 -1
  150. package/src/ui/screens/ManageAccountScreen.tsx +8 -241
  151. package/src/ui/screens/SignInEmailScreen.tsx +13 -0
  152. package/src/ui/screens/SignInSecurityScreen.tsx +19 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * "Where you're signed in": the signed-in account's sessions on every device,
3
+ * from the server (`oxy.devices.sessions`). Removing one ends that session;
4
+ * "Sign out of all other devices" ends every session but this one
5
+ * (`oxy.devices.logoutAll`) — it never signs this device out.
6
+ */
7
+
8
+ import type React from 'react';
9
+ import { useCallback, useState } from 'react';
10
+ import { GroupedSection, GroupedSectionItem } from '@oxy.so/bloom/grouped-section';
11
+ import { SpinnerIcon } from '@oxy.so/bloom/loading';
12
+ import { surfaces } from '@oxy.so/bloom/surfaces';
13
+ import { useTheme } from '@oxy.so/bloom/theme';
14
+ import { toast } from '@oxy.so/bloom/toast';
15
+ import { logger as loggerUtil, type DeviceLinkedSession } from '@oxy.so/core';
16
+ import { useOxy } from '../../context/oxyRuntimeContext';
17
+ import { useDeviceSessions } from '../../hooks/queries/useServicesQueries';
18
+ import { useI18n } from '../../hooks/useI18n';
19
+ import Ionicons from '../../icons/Ionicons';
20
+ import { deviceSessionTitle } from '../../utils/deviceSessionTitle';
21
+ import { SettingsIcon } from '../SettingsIcon';
22
+
23
+ const formatRelative = (dateString?: string): string => {
24
+ if (!dateString) {
25
+ return '—';
26
+ }
27
+ const date = new Date(dateString);
28
+ const now = new Date();
29
+ const diffMs = date.getTime() - now.getTime();
30
+ const absMin = Math.abs(diffMs) / 60000;
31
+ const isFuture = diffMs > 0;
32
+ if (absMin < 1) {
33
+ return isFuture ? 'in moments' : 'just now';
34
+ }
35
+ if (absMin < 60) {
36
+ const v = Math.floor(absMin);
37
+ return isFuture ? `in ${v}m` : `${v}m ago`;
38
+ }
39
+ const hrs = absMin / 60;
40
+ if (hrs < 24) {
41
+ const v = Math.floor(hrs);
42
+ return isFuture ? `in ${v}h` : `${v}h ago`;
43
+ }
44
+ const days = hrs / 24;
45
+ if (days < 7) {
46
+ const v = Math.floor(days);
47
+ return isFuture ? `in ${v}d` : `${v}d ago`;
48
+ }
49
+ return date.toLocaleDateString();
50
+ };
51
+
52
+ export const SessionsSection: React.FC = () => {
53
+ const bloomTheme = useTheme();
54
+ const { t } = useI18n();
55
+ const { isAuthenticated, oxyServices, activeSessionId } = useOxy();
56
+ const {
57
+ data: deviceSessions,
58
+ isLoading: deviceSessionsLoading,
59
+ refetch: refetchDeviceSessions,
60
+ } = useDeviceSessions({ enabled: isAuthenticated && !!activeSessionId });
61
+
62
+ const [removingDeviceId, setRemovingDeviceId] = useState<string | null>(null);
63
+ const [signingOutAllDevices, setSigningOutAllDevices] = useState(false);
64
+
65
+ const deviceRows: DeviceLinkedSession[] = (deviceSessions ?? []) as DeviceLinkedSession[];
66
+ const otherDevices = deviceRows.filter((d) => !d.isCurrent);
67
+
68
+ const confirmRemoveDevice = useCallback(
69
+ async (device: DeviceLinkedSession) => {
70
+ if (!activeSessionId) {
71
+ return;
72
+ }
73
+ const confirmed = await surfaces.confirm({
74
+ title: t('manageAccount.confirms.removeDeviceTitle') || 'Remove device',
75
+ description: t('manageAccount.confirms.removeDevice', {
76
+ name: deviceSessionTitle(device, t),
77
+ }),
78
+ confirmLabel: t('common.remove') || 'Remove',
79
+ cancelLabel: t('common.cancel') || 'Cancel',
80
+ destructive: true,
81
+ });
82
+ if (!confirmed) {
83
+ return;
84
+ }
85
+ setRemovingDeviceId(device.sessionId);
86
+ try {
87
+ await oxyServices.session.logout(activeSessionId, device.sessionId);
88
+ await refetchDeviceSessions();
89
+ toast.success(
90
+ t('manageAccount.toasts.deviceRemoved', { name: deviceSessionTitle(device, t) }),
91
+ );
92
+ } catch (error) {
93
+ loggerUtil.warn('Remove device failed', { component: 'SessionsSection' }, error as unknown);
94
+ toast.error(t('manageAccount.toasts.deviceRemoveFailed') || 'Failed to remove device');
95
+ } finally {
96
+ setRemovingDeviceId(null);
97
+ }
98
+ },
99
+ [activeSessionId, oxyServices, refetchDeviceSessions, t],
100
+ );
101
+
102
+ const handleSignOutAllDevices = useCallback(async () => {
103
+ if (!activeSessionId || signingOutAllDevices) {
104
+ return;
105
+ }
106
+ const confirmed = await surfaces.confirm({
107
+ title: t('manageAccount.confirms.signOutAllDevicesTitle') || 'Sign out of all other devices',
108
+ description:
109
+ t('manageAccount.confirms.signOutAllDevices', { count: otherDevices.length }) ||
110
+ `End ${otherDevices.length} other device session(s)? This won't sign you out here.`,
111
+ confirmLabel: t('common.actions.signOut') || 'Sign out',
112
+ cancelLabel: t('common.cancel') || 'Cancel',
113
+ destructive: true,
114
+ });
115
+ if (!confirmed) {
116
+ return;
117
+ }
118
+ setSigningOutAllDevices(true);
119
+ try {
120
+ await oxyServices.devices.logoutAll(activeSessionId);
121
+ await refetchDeviceSessions();
122
+ toast.success(
123
+ t('manageAccount.toasts.allDevicesSignedOut') || 'Signed out from all other devices',
124
+ );
125
+ } catch (error) {
126
+ loggerUtil.warn(
127
+ 'Sign out all devices failed',
128
+ { component: 'SessionsSection' },
129
+ error as unknown,
130
+ );
131
+ toast.error(
132
+ t('manageAccount.toasts.allDevicesFailed') || 'Failed to sign out from other devices',
133
+ );
134
+ } finally {
135
+ setSigningOutAllDevices(false);
136
+ }
137
+ }, [
138
+ activeSessionId,
139
+ signingOutAllDevices,
140
+ otherDevices.length,
141
+ oxyServices,
142
+ refetchDeviceSessions,
143
+ t,
144
+ ]);
145
+
146
+ return (
147
+ <GroupedSection title={t('manageAccount.sections.sessions') || 'Sessions & devices'}>
148
+ {deviceSessionsLoading ? (
149
+ <GroupedSectionItem
150
+ icon={<SettingsIcon name="sync" color={bloomTheme.colors.primary} />}
151
+ title={t('manageAccount.sessions.loading') || 'Loading sessions…'}
152
+ rightElement={null}
153
+ showChevron={false}
154
+ disabled
155
+ />
156
+ ) : deviceRows.length === 0 ? (
157
+ <GroupedSectionItem
158
+ icon={<SettingsIcon name="cellphone" color={bloomTheme.colors.textTertiary} />}
159
+ title={t('manageAccount.sessions.empty') || 'No active sessions'}
160
+ showChevron={false}
161
+ disabled
162
+ />
163
+ ) : (
164
+ deviceRows.map((device) => (
165
+ <GroupedSectionItem
166
+ key={`device-${device.sessionId}`}
167
+ icon={
168
+ <SettingsIcon
169
+ name={device.isCurrent ? 'cellphone' : 'cellphone-basic'}
170
+ color={device.isCurrent ? bloomTheme.colors.success : bloomTheme.colors.primary}
171
+ />
172
+ }
173
+ title={deviceSessionTitle(device, t)}
174
+ description={
175
+ t('manageAccount.sessions.lastActive', {
176
+ relative: formatRelative(device.lastActive),
177
+ }) || `Last active ${formatRelative(device.lastActive)}`
178
+ }
179
+ onPress={device.isCurrent ? undefined : () => confirmRemoveDevice(device)}
180
+ disabled={device.isCurrent || removingDeviceId === device.sessionId}
181
+ rightElement={
182
+ !device.isCurrent ? (
183
+ removingDeviceId === device.sessionId ? (
184
+ <SpinnerIcon color={bloomTheme.colors.error} size={16} />
185
+ ) : (
186
+ <Ionicons name="log-out-outline" size={18} color={bloomTheme.colors.error} />
187
+ )
188
+ ) : undefined
189
+ }
190
+ showChevron={false}
191
+ />
192
+ ))
193
+ )}
194
+ {otherDevices.length > 0 ? (
195
+ <GroupedSectionItem
196
+ icon={<SettingsIcon name="logout" color={bloomTheme.colors.error} />}
197
+ title={
198
+ t('manageAccount.sessions.signOutAllOnThisDevice') || 'Sign out of all other devices'
199
+ }
200
+ description={
201
+ t('manageAccount.sessions.signOutAllSubtitle', {
202
+ count: otherDevices.length,
203
+ }) || `End ${otherDevices.length} other device session(s)`
204
+ }
205
+ onPress={handleSignOutAllDevices}
206
+ destructive
207
+ showChevron={false}
208
+ disabled={signingOutAllDevices}
209
+ rightElement={
210
+ signingOutAllDevices ? (
211
+ <SpinnerIcon color={bloomTheme.colors.error} size={16} />
212
+ ) : undefined
213
+ }
214
+ />
215
+ ) : null}
216
+ </GroupedSection>
217
+ );
218
+ };
@@ -87,6 +87,12 @@ export function describeSignInError(error: unknown, t: Translate): string {
87
87
  return t('signup.username.taken');
88
88
  case SIGN_IN_ERROR_CODES.emailAccountsFull:
89
89
  return t('signin.errors.emailAccountsFull');
90
+ case SIGN_IN_ERROR_CODES.lastSignInMethod:
91
+ return t('signInSecurity.errors.lastSignInMethod');
92
+ case SIGN_IN_ERROR_CODES.passwordRequired:
93
+ return t('signInSecurity.errors.passwordRequired');
94
+ case SIGN_IN_ERROR_CODES.emailUnchanged:
95
+ return t('signInSecurity.errors.emailUnchanged');
90
96
  case EMAIL_VERIFICATION_ERROR_CODES.codeInvalid:
91
97
  return t('emailCode.errors.codeInvalid');
92
98
  case EMAIL_VERIFICATION_ERROR_CODES.tooManyAttempts:
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What every security panel of the signed-in account reads first: whether it
3
+ * holds a Commons key (it signs in with Commons, nothing here applies),
4
+ * whether it is a personal account (only those sign in with an email, a
5
+ * password or an authenticator — the API refuses the rest), and how it signs
6
+ * in. A panel that cannot show its methods says why instead of spinning.
7
+ */
8
+
9
+ import type React from 'react';
10
+ import { Button } from '@oxy.so/bloom/button';
11
+ import type { SignInMethods } from '@oxy.so/contracts';
12
+ import type { UseQueryResult } from '@tanstack/react-query';
13
+ import { useOxy } from '../../context/oxyRuntimeContext';
14
+ import { useSignInMethods } from '../../hooks/queries/useAuthMethods';
15
+ import type { Translate } from '../authChooser/types';
16
+ import { OxyAuthLoading, OxyAuthScreen, OxyAuthScreenHeader } from './OxyAuthScreen';
17
+ import { AccountFlowNotice } from './accountFlowParts';
18
+
19
+ export interface AccountSecurityState {
20
+ /** The account holds a Commons key. */
21
+ keyed: boolean;
22
+ /** A personal account (an organization, project or bot signs in through its people). */
23
+ personal: boolean;
24
+ methods: UseQueryResult<SignInMethods>;
25
+ }
26
+
27
+ export function useAccountSecurityState(): AccountSecurityState {
28
+ const { user } = useOxy();
29
+ const keyed = Boolean(user?.publicKey);
30
+ const personal = !user?.kind || user.kind === 'personal';
31
+ const methods = useSignInMethods({ enabled: !keyed && personal });
32
+ return { keyed, personal, methods };
33
+ }
34
+
35
+ /**
36
+ * The screen a panel shows while its methods are not there to act on: not a
37
+ * personal account, the read failed (with a retry), or still loading. `null`
38
+ * once `methods.data` is ready.
39
+ */
40
+ export function accountSecurityFallback(
41
+ state: AccountSecurityState,
42
+ title: string,
43
+ t: Translate,
44
+ ): React.ReactElement | null {
45
+ if (!state.personal) {
46
+ return (
47
+ <OxyAuthScreen>
48
+ <OxyAuthScreenHeader title={title} />
49
+ <AccountFlowNotice testID="account-security-not-personal">
50
+ {t('signInSecurity.notPersonal')}
51
+ </AccountFlowNotice>
52
+ </OxyAuthScreen>
53
+ );
54
+ }
55
+ if (state.methods.isError && !state.methods.data) {
56
+ return (
57
+ <OxyAuthScreen>
58
+ <OxyAuthScreenHeader title={title} />
59
+ <AccountFlowNotice type="error" testID="account-security-failed">
60
+ {t('signInSecurity.loadFailed')}
61
+ </AccountFlowNotice>
62
+ <Button
63
+ appearance="outline"
64
+ size="lg"
65
+ onPress={() => void state.methods.refetch()}
66
+ testID="account-security-retry"
67
+ >
68
+ {t('signInSecurity.retry')}
69
+ </Button>
70
+ </OxyAuthScreen>
71
+ );
72
+ }
73
+ if (!state.methods.data) return <OxyAuthLoading />;
74
+ return null;
75
+ }
@@ -41,7 +41,9 @@ export type RouteName =
41
41
  | 'Preferences' // General user preferences (theme, reduce-motion, etc.)
42
42
  | 'DeleteAccount' // Delete an account without a key, with a code by email
43
43
  | 'LinkCommons' // Link Commons: the account becomes self-custodied
44
- | 'SignInPassword' // Set or change the account's password
44
+ | 'SignInSecurity' // How the account signs in, and where it is signed in
45
+ | 'SignInEmail' // Add, change or remove the account's email
46
+ | 'SignInPassword' // Set, change or remove the account's password
45
47
  | 'SignInAuthenticator' // The account's authenticator app and backup codes
46
48
  | 'AccountDialog'; // Unified account switcher + sign-in surface (OxyAccountDialogScreen body)
47
49
 
@@ -114,6 +116,8 @@ const ROUTES: ReadonlySet<string> = new Set([
114
116
  'Preferences',
115
117
  'DeleteAccount',
116
118
  'LinkCommons',
119
+ 'SignInSecurity',
120
+ 'SignInEmail',
117
121
  'SignInPassword',
118
122
  'SignInAuthenticator',
119
123
  'AccountDialog',
@@ -39,6 +39,8 @@ import ConnectedAppsScreenComponent from '../screens/ConnectedAppsScreen';
39
39
  import PreferencesScreenComponent from '../screens/PreferencesScreen';
40
40
  import DeleteAccountScreenComponent from '../screens/DeleteAccountScreen';
41
41
  import LinkCommonsScreenComponent from '../screens/LinkCommonsScreen';
42
+ import SignInSecurityScreenComponent from '../screens/SignInSecurityScreen';
43
+ import SignInEmailScreenComponent from '../screens/SignInEmailScreen';
42
44
  import SignInPasswordScreenComponent from '../screens/SignInPasswordScreen';
43
45
  import SignInAuthenticatorScreenComponent from '../screens/SignInAuthenticatorScreen';
44
46
  import AccountDialogScreenComponent from '../components/OxyAccountDialogScreen';
@@ -87,6 +89,8 @@ export const screenComponents: Record<RouteName, ComponentType<never>> = {
87
89
  // The account's own sign-in security, in its settings.
88
90
  DeleteAccount: DeleteAccountScreenComponent,
89
91
  LinkCommons: LinkCommonsScreenComponent,
92
+ SignInSecurity: SignInSecurityScreenComponent,
93
+ SignInEmail: SignInEmailScreenComponent,
90
94
  SignInPassword: SignInPasswordScreenComponent,
91
95
  SignInAuthenticator: SignInAuthenticatorScreenComponent,
92
96
  // Unified account switcher + sign-in surface. Its body lives in the
@@ -81,6 +81,8 @@ export interface SurfaceRegistry {
81
81
  Preferences: SurfaceRoute;
82
82
  DeleteAccount: SurfaceRoute;
83
83
  LinkCommons: SurfaceRoute;
84
+ SignInSecurity: SurfaceRoute;
85
+ SignInEmail: SurfaceRoute;
84
86
  SignInPassword: SurfaceRoute;
85
87
  SignInAuthenticator: SurfaceRoute;
86
88
  /** Unified account switcher + sign-in surface (folded `OxyAccountDialogScreen` body). */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The frame the account's own security panels render in when presented as a
3
- * surface (`showBottomSheet('DeleteAccount' | 'LinkCommons' | 'SignInPassword'
3
+ * surface (`showBottomSheet('DeleteAccount' | 'LinkCommons' | 'SignInEmail' | 'SignInPassword'
4
4
  * | 'SignInAuthenticator')`): the panel draws its own large header, so the bar
5
5
  * holds only the way back, and the body keeps the dialog's screen gutter.
6
6
  */
@@ -4,7 +4,6 @@ import { SpinnerIcon } from '@oxy.so/bloom/loading';
4
4
  import type React from 'react';
5
5
  import { useCallback, useMemo, useState } from 'react';
6
6
  import { View, StyleSheet, Linking, Platform } from 'react-native';
7
- import Ionicons from '../icons/Ionicons';
8
7
  import { toast } from '@oxy.so/bloom/toast';
9
8
  import { surfaces } from '@oxy.so/bloom/surfaces';
10
9
  import { useTheme } from '@oxy.so/bloom/theme';
@@ -16,7 +15,6 @@ import {
16
15
  getNormalizedUserHandle,
17
16
  logger as loggerUtil,
18
17
  packageInfo,
19
- type DeviceLinkedSession,
20
18
  } from '@oxy.so/core';
21
19
  import type { BaseScreenProps } from '../types/navigation';
22
20
  import ProfileSummaryCard from '../components/ProfileSummaryCard';
@@ -29,46 +27,14 @@ import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
29
27
  import { isWebBrowser } from '../utils/isWebBrowser';
30
28
  import { useCurrentUser } from '../hooks/queries/useAccountQueries';
31
29
  import { useUserSubscription } from '../hooks/queries/usePaymentQueries';
32
- import { useDeviceSessions } from '../hooks/queries/useServicesQueries';
33
- import { deviceSessionTitle } from '../utils/deviceSessionTitle';
34
-
35
- type DeviceSessionRow = DeviceLinkedSession;
36
-
37
- const formatRelative = (dateString?: string): string => {
38
- if (!dateString) {
39
- return '—';
40
- }
41
- const date = new Date(dateString);
42
- const now = new Date();
43
- const diffMs = date.getTime() - now.getTime();
44
- const absMin = Math.abs(diffMs) / 60000;
45
- const isFuture = diffMs > 0;
46
- if (absMin < 1) {
47
- return isFuture ? 'in moments' : 'just now';
48
- }
49
- if (absMin < 60) {
50
- const v = Math.floor(absMin);
51
- return isFuture ? `in ${v}m` : `${v}m ago`;
52
- }
53
- const hrs = absMin / 60;
54
- if (hrs < 24) {
55
- const v = Math.floor(hrs);
56
- return isFuture ? `in ${v}h` : `${v}h ago`;
57
- }
58
- const days = hrs / 24;
59
- if (days < 7) {
60
- const v = Math.floor(days);
61
- return isFuture ? `in ${v}d` : `${v}d ago`;
62
- }
63
- return date.toLocaleDateString();
64
- };
65
30
 
66
31
  /**
67
32
  * Unified "Manage your Oxy Account" screen.
68
33
  *
69
34
  * Replaces AccountOverview + AccountSettings + the per-account half of
70
- * SessionManagement. Lists ONLY the active user's profile, sessions on this
71
- * device, and security/destructive actions for THIS account. Multi-account
35
+ * SessionManagement. Lists ONLY the active user's profile, its sign-in and
36
+ * security page (`SignInSecurity`: methods and sessions), and the destructive
37
+ * actions for THIS account. Multi-account
72
38
  * surface lives in the unified `OxyAccountDialogScreen` — keep these concerns separate.
73
39
  */
74
40
  interface ManageAccountScreenProps extends BaseScreenProps {
@@ -91,7 +57,6 @@ const ManageAccountScreen: React.FC<ManageAccountScreenProps> = ({
91
57
  user: contextUser,
92
58
  isAuthenticated,
93
59
  oxyServices,
94
- activeSessionId,
95
60
  logout,
96
61
  openAvatarPicker,
97
62
  accounts,
@@ -110,14 +75,6 @@ const ManageAccountScreen: React.FC<ManageAccountScreenProps> = ({
110
75
  const user = userFromQuery ?? contextUser;
111
76
 
112
77
  const { data: subscription } = useUserSubscription({ enabled: isAuthenticated });
113
- const {
114
- data: deviceSessions,
115
- isLoading: deviceSessionsLoading,
116
- refetch: refetchDeviceSessions,
117
- } = useDeviceSessions({ enabled: isAuthenticated && !!activeSessionId });
118
-
119
- const [removingDeviceId, setRemovingDeviceId] = useState<string | null>(null);
120
- const [signingOutAllDevices, setSigningOutAllDevices] = useState(false);
121
78
  const [signingOut, setSigningOut] = useState(false);
122
79
 
123
80
  const displayName = useMemo(
@@ -159,91 +116,6 @@ const ManageAccountScreen: React.FC<ManageAccountScreenProps> = ({
159
116
  }
160
117
  }, [signingOut, logout, t, onClose]);
161
118
 
162
- const confirmRemoveDevice = useCallback(
163
- async (device: DeviceSessionRow) => {
164
- if (!activeSessionId) {
165
- return;
166
- }
167
- const confirmed = await surfaces.confirm({
168
- title: t('manageAccount.confirms.removeDeviceTitle') || 'Remove device',
169
- description: t('manageAccount.confirms.removeDevice', {
170
- name: deviceSessionTitle(device, t),
171
- }),
172
- confirmLabel: t('common.remove') || 'Remove',
173
- cancelLabel: t('common.cancel') || 'Cancel',
174
- destructive: true,
175
- });
176
- if (!confirmed) {
177
- return;
178
- }
179
- setRemovingDeviceId(device.sessionId);
180
- try {
181
- await oxyServices.session.logout(activeSessionId, device.sessionId);
182
- await refetchDeviceSessions();
183
- toast.success(
184
- t('manageAccount.toasts.deviceRemoved', { name: deviceSessionTitle(device, t) }),
185
- );
186
- } catch (error) {
187
- loggerUtil.warn(
188
- 'Remove device failed',
189
- { component: 'ManageAccountScreen' },
190
- error as unknown,
191
- );
192
- toast.error(t('manageAccount.toasts.deviceRemoveFailed') || 'Failed to remove device');
193
- } finally {
194
- setRemovingDeviceId(null);
195
- }
196
- },
197
- [activeSessionId, oxyServices, refetchDeviceSessions, t],
198
- );
199
-
200
- const handleSignOutAllDevices = useCallback(async () => {
201
- if (!activeSessionId || signingOutAllDevices) {
202
- return;
203
- }
204
- const otherDeviceCount = ((deviceSessions ?? []) as DeviceSessionRow[]).filter(
205
- (device) => !device.isCurrent,
206
- ).length;
207
- const confirmed = await surfaces.confirm({
208
- title: t('manageAccount.confirms.signOutAllDevicesTitle') || 'Sign out of all other devices',
209
- description:
210
- t('manageAccount.confirms.signOutAllDevices', { count: otherDeviceCount }) ||
211
- `End ${otherDeviceCount} other device session(s)? This won't sign you out here.`,
212
- confirmLabel: t('common.actions.signOut') || 'Sign out',
213
- cancelLabel: t('common.cancel') || 'Cancel',
214
- destructive: true,
215
- });
216
- if (!confirmed) {
217
- return;
218
- }
219
- setSigningOutAllDevices(true);
220
- try {
221
- await oxyServices.devices.logoutAll(activeSessionId);
222
- await refetchDeviceSessions();
223
- toast.success(
224
- t('manageAccount.toasts.allDevicesSignedOut') || 'Signed out from all other devices',
225
- );
226
- } catch (error) {
227
- loggerUtil.warn(
228
- 'Sign out all devices failed',
229
- { component: 'ManageAccountScreen' },
230
- error as unknown,
231
- );
232
- toast.error(
233
- t('manageAccount.toasts.allDevicesFailed') || 'Failed to sign out from other devices',
234
- );
235
- } finally {
236
- setSigningOutAllDevices(false);
237
- }
238
- }, [
239
- activeSessionId,
240
- signingOutAllDevices,
241
- deviceSessions,
242
- oxyServices,
243
- refetchDeviceSessions,
244
- t,
245
- ]);
246
-
247
119
  // The signed, open-format export (`GET /users/me/export`): one JSON bundle
248
120
  // carrying Oxy's provenance attestation — the "credible exit" snapshot.
249
121
  const handleDownloadData = useCallback(async () => {
@@ -347,9 +219,6 @@ const ManageAccountScreen: React.FC<ManageAccountScreenProps> = ({
347
219
  );
348
220
  }
349
221
 
350
- const deviceRows: DeviceSessionRow[] = (deviceSessions ?? []) as DeviceSessionRow[];
351
- const otherDevices = deviceRows.filter((d) => !d.isCurrent);
352
-
353
222
  return (
354
223
  <>
355
224
  <View className="px-screen-margin pb-space-24">
@@ -432,117 +301,15 @@ const ManageAccountScreen: React.FC<ManageAccountScreenProps> = ({
432
301
  </GroupedSection>
433
302
  ) : null}
434
303
 
435
- {/* Sessions section */}
436
- <GroupedSection title={t('manageAccount.sections.sessions') || 'Sessions & devices'}>
437
- {deviceSessionsLoading ? (
438
- <GroupedSectionItem
439
- icon={<SettingsIcon name="sync" color={bloomTheme.colors.primary} />}
440
- title={t('manageAccount.sessions.loading') || 'Loading sessions…'}
441
- rightElement={null}
442
- showChevron={false}
443
- disabled
444
- />
445
- ) : deviceRows.length === 0 ? (
446
- <GroupedSectionItem
447
- icon={<SettingsIcon name="cellphone" color={bloomTheme.colors.textTertiary} />}
448
- title={t('manageAccount.sessions.empty') || 'No active sessions'}
449
- showChevron={false}
450
- disabled
451
- />
452
- ) : (
453
- deviceRows.map((device) => (
454
- <GroupedSectionItem
455
- key={`device-${device.sessionId}`}
456
- icon={
457
- <SettingsIcon
458
- name={device.isCurrent ? 'cellphone' : 'cellphone-basic'}
459
- color={
460
- device.isCurrent ? bloomTheme.colors.success : bloomTheme.colors.primary
461
- }
462
- />
463
- }
464
- title={deviceSessionTitle(device, t)}
465
- description={
466
- t('manageAccount.sessions.lastActive', {
467
- relative: formatRelative(device.lastActive),
468
- }) || `Last active ${formatRelative(device.lastActive)}`
469
- }
470
- onPress={device.isCurrent ? undefined : () => confirmRemoveDevice(device)}
471
- disabled={device.isCurrent || removingDeviceId === device.sessionId}
472
- rightElement={
473
- !device.isCurrent ? (
474
- removingDeviceId === device.sessionId ? (
475
- <SpinnerIcon color={bloomTheme.colors.error} size={16} />
476
- ) : (
477
- <Ionicons
478
- name="log-out-outline"
479
- size={18}
480
- color={bloomTheme.colors.error}
481
- />
482
- )
483
- ) : undefined
484
- }
485
- showChevron={false}
486
- />
487
- ))
488
- )}
489
- {otherDevices.length > 0 ? (
490
- <GroupedSectionItem
491
- icon={<SettingsIcon name="logout" color={bloomTheme.colors.error} />}
492
- title={
493
- t('manageAccount.sessions.signOutAllOnThisDevice') ||
494
- 'Sign out of all other devices'
495
- }
496
- description={
497
- t('manageAccount.sessions.signOutAllSubtitle', {
498
- count: otherDevices.length,
499
- }) || `End ${otherDevices.length} other device session(s)`
500
- }
501
- onPress={handleSignOutAllDevices}
502
- destructive
503
- showChevron={false}
504
- disabled={signingOutAllDevices}
505
- rightElement={
506
- signingOutAllDevices ? (
507
- <SpinnerIcon color={bloomTheme.colors.error} size={16} />
508
- ) : undefined
509
- }
510
- />
511
- ) : null}
512
- </GroupedSection>
513
-
514
- {/* Security section */}
304
+ {/* Security: one page for how the account signs in and where. */}
515
305
  <GroupedSection title={t('manageAccount.sections.security') || 'Security'}>
516
306
  <GroupedSectionItem
517
307
  icon={<SettingsIcon name="shield-check" color={bloomTheme.colors.primary} />}
518
- title={t('manageAccount.items.security.title') || 'Security settings'}
519
- description={
520
- t('manageAccount.items.security.subtitle') || 'Password, 2FA, recovery'
521
- }
522
- onPress={() => navigate?.('PrivacySettings')}
308
+ title={t('signInSecurity.title')}
309
+ description={t('signInSecurity.subtitle')}
310
+ onPress={() => navigate?.('SignInSecurity')}
311
+ testID="manage-account-sign-in-security"
523
312
  />
524
- {user && !user.publicKey ? (
525
- <>
526
- <GroupedSectionItem
527
- icon={<SettingsIcon name="key" color={bloomTheme.colors.primary} />}
528
- title={t('signInSecurity.password.title')}
529
- description={t('signInSecurity.password.row')}
530
- onPress={() => navigate?.('SignInPassword')}
531
- />
532
- <GroupedSectionItem
533
- icon={<SettingsIcon name="shield-check" color={bloomTheme.colors.primary} />}
534
- title={t('signInSecurity.totp.title')}
535
- description={t('signInSecurity.totp.row')}
536
- onPress={() => navigate?.('SignInAuthenticator')}
537
- />
538
- <GroupedSectionItem
539
- icon={<SettingsIcon name="link" color={bloomTheme.colors.primary} />}
540
- title={t('linkCommons.title')}
541
- description={t('linkCommons.row')}
542
- onPress={() => navigate?.('LinkCommons')}
543
- />
544
- </>
545
- ) : null}
546
313
  {user?.isPremium || subscription?.status === 'active' ? (
547
314
  <GroupedSectionItem
548
315
  icon={<SettingsIcon name="credit-card" color={bloomTheme.colors.success} />}