@oxy.so/services 12.3.0 → 12.5.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 (187) 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/authChooser/types.js +14 -8
  5. package/lib/commonjs/ui/components/authChooser/types.js.map +1 -1
  6. package/lib/commonjs/ui/components/signIn/OxyAuthenticatorPanel.js +11 -10
  7. package/lib/commonjs/ui/components/signIn/OxyAuthenticatorPanel.js.map +1 -1
  8. package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js +19 -18
  9. package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -1
  10. package/lib/commonjs/ui/components/signIn/OxyEmailPanel.js +251 -0
  11. package/lib/commonjs/ui/components/signIn/OxyEmailPanel.js.map +1 -0
  12. package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js +40 -33
  13. package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -1
  14. package/lib/commonjs/ui/components/signIn/OxyPasswordPanel.js +51 -19
  15. package/lib/commonjs/ui/components/signIn/OxyPasswordPanel.js.map +1 -1
  16. package/lib/commonjs/ui/components/signIn/OxySignInSecurityPanel.js +154 -0
  17. package/lib/commonjs/ui/components/signIn/OxySignInSecurityPanel.js.map +1 -0
  18. package/lib/commonjs/ui/components/signIn/ReauthStep.js +6 -4
  19. package/lib/commonjs/ui/components/signIn/ReauthStep.js.map +1 -1
  20. package/lib/commonjs/ui/components/signIn/SessionsSection.js +200 -0
  21. package/lib/commonjs/ui/components/signIn/SessionsSection.js.map +1 -0
  22. package/lib/commonjs/ui/components/signIn/accountFlowParts.js +6 -0
  23. package/lib/commonjs/ui/components/signIn/accountFlowParts.js.map +1 -1
  24. package/lib/commonjs/ui/components/signIn/accountSecurityState.js +74 -0
  25. package/lib/commonjs/ui/components/signIn/accountSecurityState.js.map +1 -0
  26. package/lib/commonjs/ui/navigation/routes.js +1 -1
  27. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  28. package/lib/commonjs/ui/navigation/screenComponents.js +4 -0
  29. package/lib/commonjs/ui/navigation/screenComponents.js.map +1 -1
  30. package/lib/commonjs/ui/navigation/surfaceRegistry.js.map +1 -1
  31. package/lib/commonjs/ui/screens/AccountSecurityPanelScreen.js +1 -1
  32. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +3 -12
  33. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  34. package/lib/commonjs/ui/screens/CreateAccountScreen.js +3 -12
  35. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  36. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +7 -3
  37. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
  38. package/lib/commonjs/ui/screens/ManageAccountScreen.js +8 -196
  39. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  40. package/lib/commonjs/ui/screens/SignInEmailScreen.js +19 -0
  41. package/lib/commonjs/ui/screens/SignInEmailScreen.js.map +1 -0
  42. package/lib/commonjs/ui/screens/SignInSecurityScreen.js +30 -0
  43. package/lib/commonjs/ui/screens/SignInSecurityScreen.js.map +1 -0
  44. package/lib/module/index.js +5 -2
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/module/ui/components/authChooser/types.js +15 -8
  47. package/lib/module/ui/components/authChooser/types.js.map +1 -1
  48. package/lib/module/ui/components/signIn/OxyAuthenticatorPanel.js +12 -11
  49. package/lib/module/ui/components/signIn/OxyAuthenticatorPanel.js.map +1 -1
  50. package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js +20 -19
  51. package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -1
  52. package/lib/module/ui/components/signIn/OxyEmailPanel.js +246 -0
  53. package/lib/module/ui/components/signIn/OxyEmailPanel.js.map +1 -0
  54. package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js +40 -33
  55. package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -1
  56. package/lib/module/ui/components/signIn/OxyPasswordPanel.js +52 -20
  57. package/lib/module/ui/components/signIn/OxyPasswordPanel.js.map +1 -1
  58. package/lib/module/ui/components/signIn/OxySignInSecurityPanel.js +149 -0
  59. package/lib/module/ui/components/signIn/OxySignInSecurityPanel.js.map +1 -0
  60. package/lib/module/ui/components/signIn/ReauthStep.js +6 -4
  61. package/lib/module/ui/components/signIn/ReauthStep.js.map +1 -1
  62. package/lib/module/ui/components/signIn/SessionsSection.js +194 -0
  63. package/lib/module/ui/components/signIn/SessionsSection.js.map +1 -0
  64. package/lib/module/ui/components/signIn/accountFlowParts.js +6 -0
  65. package/lib/module/ui/components/signIn/accountFlowParts.js.map +1 -1
  66. package/lib/module/ui/components/signIn/accountSecurityState.js +69 -0
  67. package/lib/module/ui/components/signIn/accountSecurityState.js.map +1 -0
  68. package/lib/module/ui/navigation/routes.js +1 -1
  69. package/lib/module/ui/navigation/routes.js.map +1 -1
  70. package/lib/module/ui/navigation/screenComponents.js +4 -0
  71. package/lib/module/ui/navigation/screenComponents.js.map +1 -1
  72. package/lib/module/ui/navigation/surfaceRegistry.js.map +1 -1
  73. package/lib/module/ui/screens/AccountSecurityPanelScreen.js +1 -1
  74. package/lib/module/ui/screens/AccountSettingsScreen.js +4 -13
  75. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  76. package/lib/module/ui/screens/CreateAccountScreen.js +3 -13
  77. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  78. package/lib/module/ui/screens/EditProfileFieldScreen.js +8 -4
  79. package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
  80. package/lib/module/ui/screens/ManageAccountScreen.js +9 -197
  81. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  82. package/lib/module/ui/screens/SignInEmailScreen.js +17 -0
  83. package/lib/module/ui/screens/SignInEmailScreen.js.map +1 -0
  84. package/lib/module/ui/screens/SignInSecurityScreen.js +26 -0
  85. package/lib/module/ui/screens/SignInSecurityScreen.js.map +1 -0
  86. package/lib/typescript/commonjs/index.d.ts +4 -0
  87. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts +1 -3
  89. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/ui/components/signIn/OxyAuthenticatorPanel.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts +3 -2
  92. package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/ui/components/signIn/OxyEmailPanel.d.ts +21 -0
  94. package/lib/typescript/commonjs/ui/components/signIn/OxyEmailPanel.d.ts.map +1 -0
  95. package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts +2 -1
  96. package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/components/signIn/OxyPasswordPanel.d.ts +5 -4
  98. package/lib/typescript/commonjs/ui/components/signIn/OxyPasswordPanel.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/components/signIn/OxySignInSecurityPanel.d.ts +25 -0
  100. package/lib/typescript/commonjs/ui/components/signIn/OxySignInSecurityPanel.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/ui/components/signIn/ReauthStep.d.ts +8 -2
  102. package/lib/typescript/commonjs/ui/components/signIn/ReauthStep.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/ui/components/signIn/SessionsSection.d.ts +9 -0
  104. package/lib/typescript/commonjs/ui/components/signIn/SessionsSection.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/components/signIn/accountSecurityState.d.ts +26 -0
  107. package/lib/typescript/commonjs/ui/components/signIn/accountSecurityState.d.ts.map +1 -0
  108. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
  109. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
  111. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/navigation/screenComponents.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts +2 -0
  114. package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/screens/AccountSecurityPanelScreen.d.ts +1 -1
  116. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts +3 -2
  120. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/ui/screens/SignInEmailScreen.d.ts +6 -0
  122. package/lib/typescript/commonjs/ui/screens/SignInEmailScreen.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/ui/screens/SignInSecurityScreen.d.ts +6 -0
  124. package/lib/typescript/commonjs/ui/screens/SignInSecurityScreen.d.ts.map +1 -0
  125. package/lib/typescript/module/index.d.ts +4 -0
  126. package/lib/typescript/module/index.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/components/authChooser/types.d.ts +1 -3
  128. package/lib/typescript/module/ui/components/authChooser/types.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/components/signIn/OxyAuthenticatorPanel.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts +3 -2
  131. package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/components/signIn/OxyEmailPanel.d.ts +21 -0
  133. package/lib/typescript/module/ui/components/signIn/OxyEmailPanel.d.ts.map +1 -0
  134. package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts +2 -1
  135. package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -1
  136. package/lib/typescript/module/ui/components/signIn/OxyPasswordPanel.d.ts +5 -4
  137. package/lib/typescript/module/ui/components/signIn/OxyPasswordPanel.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/components/signIn/OxySignInSecurityPanel.d.ts +25 -0
  139. package/lib/typescript/module/ui/components/signIn/OxySignInSecurityPanel.d.ts.map +1 -0
  140. package/lib/typescript/module/ui/components/signIn/ReauthStep.d.ts +8 -2
  141. package/lib/typescript/module/ui/components/signIn/ReauthStep.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/components/signIn/SessionsSection.d.ts +9 -0
  143. package/lib/typescript/module/ui/components/signIn/SessionsSection.d.ts.map +1 -0
  144. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts.map +1 -1
  145. package/lib/typescript/module/ui/components/signIn/accountSecurityState.d.ts +26 -0
  146. package/lib/typescript/module/ui/components/signIn/accountSecurityState.d.ts.map +1 -0
  147. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -2
  148. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  149. package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
  150. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  151. package/lib/typescript/module/ui/navigation/screenComponents.d.ts.map +1 -1
  152. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts +2 -0
  153. package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts.map +1 -1
  154. package/lib/typescript/module/ui/screens/AccountSecurityPanelScreen.d.ts +1 -1
  155. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  156. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  157. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  158. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts +3 -2
  159. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  160. package/lib/typescript/module/ui/screens/SignInEmailScreen.d.ts +6 -0
  161. package/lib/typescript/module/ui/screens/SignInEmailScreen.d.ts.map +1 -0
  162. package/lib/typescript/module/ui/screens/SignInSecurityScreen.d.ts +6 -0
  163. package/lib/typescript/module/ui/screens/SignInSecurityScreen.d.ts.map +1 -0
  164. package/package.json +6 -6
  165. package/src/index.ts +7 -2
  166. package/src/ui/components/authChooser/__tests__/profileColors.test.ts +13 -0
  167. package/src/ui/components/authChooser/types.ts +16 -15
  168. package/src/ui/components/signIn/OxyAuthenticatorPanel.tsx +11 -8
  169. package/src/ui/components/signIn/OxyDeleteAccountPanel.tsx +15 -16
  170. package/src/ui/components/signIn/OxyEmailPanel.tsx +258 -0
  171. package/src/ui/components/signIn/OxyLinkCommonsPanel.tsx +27 -15
  172. package/src/ui/components/signIn/OxyPasswordPanel.tsx +49 -14
  173. package/src/ui/components/signIn/OxySignInSecurityPanel.tsx +147 -0
  174. package/src/ui/components/signIn/ReauthStep.tsx +13 -4
  175. package/src/ui/components/signIn/SessionsSection.tsx +218 -0
  176. package/src/ui/components/signIn/accountFlowParts.tsx +6 -0
  177. package/src/ui/components/signIn/accountSecurityState.tsx +75 -0
  178. package/src/ui/navigation/routes.ts +5 -1
  179. package/src/ui/navigation/screenComponents.ts +4 -0
  180. package/src/ui/navigation/surfaceRegistry.ts +2 -0
  181. package/src/ui/screens/AccountSecurityPanelScreen.tsx +1 -1
  182. package/src/ui/screens/AccountSettingsScreen.tsx +10 -29
  183. package/src/ui/screens/CreateAccountScreen.tsx +9 -27
  184. package/src/ui/screens/EditProfileFieldScreen.tsx +10 -8
  185. package/src/ui/screens/ManageAccountScreen.tsx +8 -241
  186. package/src/ui/screens/SignInEmailScreen.tsx +13 -0
  187. package/src/ui/screens/SignInSecurityScreen.tsx +19 -0
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Setting or changing the account's password, from its settings. A password
3
- * is optional: the account signs in with a code by email without one. The
4
- * change is confirmed with the current password (when there is one) or a code
5
- * sent to the account's email, plus the authenticator's code when it has one.
2
+ * Setting, changing or removing the account's password, from its settings. A
3
+ * password is optional: the account signs in with a code by email without
4
+ * one, and it can be removed while the account has an email. Each change is
5
+ * confirmed with the current password (when there is one) or a code sent to
6
+ * the account's email, plus the authenticator's code when it has one.
6
7
  */
7
8
 
8
9
  import type React from 'react';
@@ -14,12 +15,13 @@ import { toast } from '@oxy.so/bloom/toast';
14
15
  import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH } from '@oxy.so/contracts/client';
15
16
  import { useQueryClient } from '@tanstack/react-query';
16
17
  import { useOxy } from '../../context/oxyRuntimeContext';
17
- import { useSignInMethods } from '../../hooks/queries/useAuthMethods';
18
18
  import { queryKeys } from '../../hooks/queries/queryKeys';
19
19
  import { useI18n } from '../../hooks/useI18n';
20
- import { OxyAuthLoading, OxyAuthScreen, OxyAuthScreenHeader } from './OxyAuthScreen';
20
+ import { SubtleLink } from '../authChooser/primitives';
21
+ import { OxyAuthScreen, OxyAuthScreenHeader } from './OxyAuthScreen';
21
22
  import { ReauthStep } from './ReauthStep';
22
23
  import { AccountFlowField } from './accountFlowParts';
24
+ import { accountSecurityFallback, useAccountSecurityState } from './accountSecurityState';
23
25
 
24
26
  export interface OxyPasswordPanelProps {
25
27
  /** The password is saved. */
@@ -30,15 +32,15 @@ export interface OxyPasswordPanelProps {
30
32
 
31
33
  export const OxyPasswordPanel: React.FC<OxyPasswordPanelProps> = ({ onDone, onCancel }) => {
32
34
  const { t } = useI18n();
33
- const { oxyServices, user } = useOxy();
35
+ const { oxyServices } = useOxy();
34
36
  const queryClient = useQueryClient();
35
- const keyed = Boolean(user?.publicKey);
36
- const methods = useSignInMethods({ enabled: !keyed });
37
+ const state = useAccountSecurityState();
38
+ const [removing, setRemoving] = useState(false);
37
39
  const [password, setPassword] = useState('');
38
40
  const [repeat, setRepeat] = useState('');
39
41
  const [signOutOthers, setSignOutOthers] = useState(false);
40
42
 
41
- if (keyed) {
43
+ if (state.keyed) {
42
44
  return (
43
45
  <OxyAuthScreen>
44
46
  <OxyAuthScreenHeader
@@ -48,9 +50,33 @@ export const OxyPasswordPanel: React.FC<OxyPasswordPanelProps> = ({ onDone, onCa
48
50
  </OxyAuthScreen>
49
51
  );
50
52
  }
51
- if (!methods.data) return <OxyAuthLoading />;
53
+ const fallback = accountSecurityFallback(state, t('signInSecurity.password.title'), t);
54
+ if (fallback || !state.methods.data) return fallback;
55
+ const { hasEmail, hasPassword, totpEnabled } = state.methods.data;
56
+ const refresh = () =>
57
+ void queryClient.invalidateQueries({ queryKey: queryKeys.signInMethods.all });
52
58
 
53
- const hasPassword = methods.data.hasPassword;
59
+ if (removing) {
60
+ return (
61
+ <ReauthStep
62
+ title={t('signInSecurity.password.removeTitle')}
63
+ description={t('signInSecurity.password.removeDescription')}
64
+ action="change_password"
65
+ allowPassword
66
+ totpEnabled={totpEnabled}
67
+ submitLabel={t('signInSecurity.password.remove')}
68
+ destructive
69
+ onSubmit={async (reauth) => {
70
+ await oxyServices.auth.password.remove(reauth);
71
+ refresh();
72
+ toast.success(t('signInSecurity.password.removed'));
73
+ setRemoving(false);
74
+ onDone?.();
75
+ }}
76
+ secondary={{ label: t('common.cancel'), onPress: () => setRemoving(false) }}
77
+ />
78
+ );
79
+ }
54
80
 
55
81
  return (
56
82
  <ReauthStep
@@ -62,7 +88,8 @@ export const OxyPasswordPanel: React.FC<OxyPasswordPanelProps> = ({ onDone, onCa
62
88
  description={t('signInSecurity.password.description', { min: PASSWORD_MIN_LENGTH })}
63
89
  action="change_password"
64
90
  allowPassword={hasPassword}
65
- totpEnabled={methods.data.totpEnabled}
91
+ allowEmail={hasEmail}
92
+ totpEnabled={totpEnabled}
66
93
  submitLabel={t('signInSecurity.password.save')}
67
94
  validate={() => {
68
95
  if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) {
@@ -76,7 +103,7 @@ export const OxyPasswordPanel: React.FC<OxyPasswordPanelProps> = ({ onDone, onCa
76
103
  reauth,
77
104
  revokeOtherSessions: signOutOthers,
78
105
  });
79
- void queryClient.invalidateQueries({ queryKey: queryKeys.signInMethods.all });
106
+ refresh();
80
107
  toast.success(t('signInSecurity.password.saved'));
81
108
  onDone?.();
82
109
  }}
@@ -112,6 +139,14 @@ export const OxyPasswordPanel: React.FC<OxyPasswordPanelProps> = ({ onDone, onCa
112
139
  testID="password-sign-out-others"
113
140
  />
114
141
  </Field>
142
+ {/* Without an email the password is the one way in: it stays. */}
143
+ {hasPassword && hasEmail ? (
144
+ <SubtleLink
145
+ label={t('signInSecurity.password.remove')}
146
+ onPress={() => setRemoving(true)}
147
+ testID="password-remove"
148
+ />
149
+ ) : null}
115
150
  </ReauthStep>
116
151
  );
117
152
  };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * "Sign-in & security": every way the signed-in account signs in, each with
3
+ * its state, and every place it is signed in. One page, rendered by the
4
+ * account dialog's "Manage your account" (`SignInSecurity`) and by the
5
+ * Accounts app's security page.
6
+ *
7
+ * Each method opens its own panel: the email (`SignInEmail`), the password
8
+ * (`SignInPassword`), the authenticator app (`SignInAuthenticator`) and
9
+ * linking Commons (`LinkCommons`). An account with a Commons key signs in with
10
+ * Commons, so it sees only that; an organization, project or bot has no
11
+ * methods of its own, only its sessions.
12
+ */
13
+
14
+ import type React from 'react';
15
+ import { View } from 'react-native';
16
+ import { GroupedSection, GroupedSectionItem } from '@oxy.so/bloom/grouped-section';
17
+ import { useTheme } from '@oxy.so/bloom/theme';
18
+ import { useOxy } from '../../context/oxyRuntimeContext';
19
+ import { useI18n } from '../../hooks/useI18n';
20
+ import type { RouteName } from '../../navigation/routes';
21
+ import { SettingsIcon } from '../SettingsIcon';
22
+ import { AccountFlowNotice } from './accountFlowParts';
23
+ import { useAccountSecurityState } from './accountSecurityState';
24
+ import { SessionsSection } from './SessionsSection';
25
+
26
+ type SignInSecurityRoute = Extract<
27
+ RouteName,
28
+ 'SignInEmail' | 'SignInPassword' | 'SignInAuthenticator' | 'LinkCommons'
29
+ >;
30
+
31
+ export interface OxySignInSecurityPanelProps {
32
+ /**
33
+ * Open one method's panel. Defaults to the provider's `showBottomSheet`;
34
+ * a surface passes its own `navigate` so the panel stacks inside it.
35
+ */
36
+ onOpen?: (route: SignInSecurityRoute) => void;
37
+ }
38
+
39
+ export const OxySignInSecurityPanel: React.FC<OxySignInSecurityPanelProps> = ({ onOpen }) => {
40
+ const theme = useTheme();
41
+ const { t } = useI18n();
42
+ const { user, showBottomSheet } = useOxy();
43
+ const { keyed, personal, methods } = useAccountSecurityState();
44
+ const open = (route: SignInSecurityRoute) => () =>
45
+ onOpen ? onOpen(route) : showBottomSheet?.(route);
46
+ const on = theme.colors.success;
47
+ const off = theme.colors.textTertiary;
48
+
49
+ const renderMethods = (): React.ReactNode => {
50
+ if (keyed) {
51
+ return (
52
+ <GroupedSectionItem
53
+ icon={<SettingsIcon name="key" color={on} />}
54
+ title={t('signInSecurity.commons.title')}
55
+ description={t('linkCommons.already')}
56
+ showChevron={false}
57
+ disabled
58
+ />
59
+ );
60
+ }
61
+ if (!personal) {
62
+ return (
63
+ <GroupedSectionItem
64
+ icon={<SettingsIcon name="information" color={off} />}
65
+ title={t('signInSecurity.notPersonal')}
66
+ showChevron={false}
67
+ disabled
68
+ />
69
+ );
70
+ }
71
+ if (!methods.data) {
72
+ return methods.isError ? (
73
+ <GroupedSectionItem
74
+ icon={<SettingsIcon name="alert-circle" color={theme.colors.error} />}
75
+ title={t('signInSecurity.loadFailed')}
76
+ description={t('signInSecurity.retry')}
77
+ onPress={() => void methods.refetch()}
78
+ testID="sign-in-security-retry"
79
+ />
80
+ ) : (
81
+ <GroupedSectionItem
82
+ icon={<SettingsIcon name="sync" color={theme.colors.primary} />}
83
+ title={t('common.status.loading')}
84
+ showChevron={false}
85
+ disabled
86
+ />
87
+ );
88
+ }
89
+ const { hasEmail, hasPassword, totpEnabled, backupCodesRemaining } = methods.data;
90
+ return (
91
+ <>
92
+ <GroupedSectionItem
93
+ icon={<SettingsIcon name="email" color={hasEmail ? on : off} />}
94
+ title={t('signInSecurity.email.title')}
95
+ description={
96
+ hasEmail
97
+ ? (user?.email ?? t('signInSecurity.status.on'))
98
+ : t('signInSecurity.email.none')
99
+ }
100
+ onPress={open('SignInEmail')}
101
+ testID="sign-in-security-email"
102
+ />
103
+ <GroupedSectionItem
104
+ icon={<SettingsIcon name="key" color={hasPassword ? on : off} />}
105
+ title={t('signInSecurity.password.title')}
106
+ description={
107
+ hasPassword ? t('signInSecurity.status.on') : t('signInSecurity.password.row')
108
+ }
109
+ onPress={open('SignInPassword')}
110
+ testID="sign-in-security-password"
111
+ />
112
+ <GroupedSectionItem
113
+ icon={<SettingsIcon name="shield-check" color={totpEnabled ? on : off} />}
114
+ title={t('signInSecurity.totp.title')}
115
+ description={
116
+ totpEnabled
117
+ ? t('signInSecurity.totp.onWithCodes', { count: backupCodesRemaining })
118
+ : t('signInSecurity.totp.row')
119
+ }
120
+ onPress={open('SignInAuthenticator')}
121
+ testID="sign-in-security-totp"
122
+ />
123
+ <GroupedSectionItem
124
+ icon={<SettingsIcon name="link" color={theme.colors.primary} />}
125
+ title={t('linkCommons.title')}
126
+ description={t('linkCommons.row')}
127
+ onPress={open('LinkCommons')}
128
+ testID="sign-in-security-commons"
129
+ />
130
+ </>
131
+ );
132
+ };
133
+
134
+ const noEmail = personal && !keyed && methods.data?.hasEmail === false;
135
+
136
+ return (
137
+ <View>
138
+ <GroupedSection title={t('signInSecurity.methodsTitle')}>{renderMethods()}</GroupedSection>
139
+ {noEmail ? (
140
+ <AccountFlowNotice type="warning" testID="sign-in-security-no-email">
141
+ {t('signInSecurity.noEmailWarning')}
142
+ </AccountFlowNotice>
143
+ ) : null}
144
+ <SessionsSection />
145
+ </View>
146
+ );
147
+ };
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * "Confirm it's you": the fresh proof a sensitive change carries (`reauth`) —
3
3
  * a code sent to the account's email for THIS change, or the current password
4
- * where the change accepts one — plus the authenticator's code (or a backup
5
- * code) when the account has one. The screen that needs it puts its own fields
4
+ * where the change accepts one (the only proof of an account without an
5
+ * email) — plus the authenticator's code (or a backup code) when the account
6
+ * has one. The screen that needs it puts its own fields
6
7
  * above (`children`) and names its own action; this owns the proof.
7
8
  */
8
9
 
@@ -34,6 +35,11 @@ export interface ReauthStepProps {
34
35
  action: ReauthAction;
35
36
  /** The account has a password, and this change accepts it. */
36
37
  allowPassword?: boolean;
38
+ /**
39
+ * The account has an email, and this change accepts a code sent to it.
40
+ * Without it the proof is the password alone.
41
+ */
42
+ allowEmail?: boolean;
37
43
  /** The account has an authenticator: its code is required too. */
38
44
  totpEnabled: boolean;
39
45
  /** The screen's action, once the proof is ready. */
@@ -53,6 +59,7 @@ export const ReauthStep: React.FC<ReauthStepProps> = ({
53
59
  description,
54
60
  action,
55
61
  allowPassword = false,
62
+ allowEmail = true,
56
63
  totpEnabled,
57
64
  submitLabel,
58
65
  destructive,
@@ -63,7 +70,9 @@ export const ReauthStep: React.FC<ReauthStepProps> = ({
63
70
  }) => {
64
71
  const { t } = useI18n();
65
72
  const { oxyServices } = useOxy();
66
- const [mode, setMode] = useState<'email' | 'password'>(allowPassword ? 'password' : 'email');
73
+ const [mode, setMode] = useState<'email' | 'password'>(
74
+ allowPassword || !allowEmail ? 'password' : 'email',
75
+ );
67
76
  const [verificationId, setVerificationId] = useState<string | null>(null);
68
77
  const [emailCode, setEmailCode] = useState('');
69
78
  const [password, setPassword] = useState('');
@@ -221,7 +230,7 @@ export const ReauthStep: React.FC<ReauthStepProps> = ({
221
230
  testID="reauth-resend"
222
231
  />
223
232
  ) : null}
224
- {allowPassword ? (
233
+ {allowPassword && allowEmail ? (
225
234
  <SubtleLink
226
235
  label={mode === 'password' ? t('reauth.useEmail') : t('reauth.usePassword')}
227
236
  onPress={() => {
@@ -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). */