@oxy.so/services 6.0.0 → 6.2.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 (172) hide show
  1. package/README.md +1 -1
  2. package/lib/commonjs/index.js +32 -0
  3. package/lib/commonjs/index.js.map +1 -1
  4. package/lib/commonjs/ui/components/OxyAuthChooser.js +8 -8
  5. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
  6. package/lib/commonjs/ui/components/SurfaceScreen.js +21 -12
  7. package/lib/commonjs/ui/components/SurfaceScreen.js.map +1 -1
  8. package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js +1 -8
  9. package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js.map +1 -1
  10. package/lib/commonjs/ui/components/authChooser/types.js.map +1 -1
  11. package/lib/commonjs/ui/components/signIn/OxyAuthScreen.js +11 -5
  12. package/lib/commonjs/ui/components/signIn/OxyAuthScreen.js.map +1 -1
  13. package/lib/commonjs/ui/components/signIn/OxyCreateAccountPanel.js +236 -0
  14. package/lib/commonjs/ui/components/signIn/OxyCreateAccountPanel.js.map +1 -0
  15. package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js +114 -0
  16. package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -0
  17. package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js +275 -0
  18. package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -0
  19. package/lib/commonjs/ui/components/signIn/OxyRecoverAccountPanel.js +170 -0
  20. package/lib/commonjs/ui/components/signIn/OxyRecoverAccountPanel.js.map +1 -0
  21. package/lib/commonjs/ui/components/signIn/OxySignInPanel.js +45 -32
  22. package/lib/commonjs/ui/components/signIn/OxySignInPanel.js.map +1 -1
  23. package/lib/commonjs/ui/components/signIn/accountFlowParts.js +244 -0
  24. package/lib/commonjs/ui/components/signIn/accountFlowParts.js.map +1 -0
  25. package/lib/commonjs/ui/components/signIn/signInMethods.js +19 -18
  26. package/lib/commonjs/ui/components/signIn/signInMethods.js.map +1 -1
  27. package/lib/commonjs/ui/context/OxyContext.js +4 -4
  28. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  29. package/lib/commonjs/ui/context/passkeyFlow.js +22 -0
  30. package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -1
  31. package/lib/commonjs/ui/hooks/useSurfaceFrameWidth.js +28 -0
  32. package/lib/commonjs/ui/hooks/useSurfaceFrameWidth.js.map +1 -0
  33. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +0 -23
  34. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
  35. package/lib/commonjs/ui/screens/EditProfileScreen.js +0 -8
  36. package/lib/commonjs/ui/screens/EditProfileScreen.js.map +1 -1
  37. package/lib/commonjs/ui/screens/ManageAccountScreen.js +3 -3
  38. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  39. package/lib/commonjs/ui/utils/accountDeletionHandoff.js +2 -2
  40. package/lib/commonjs/ui/utils/accountDeletionHandoff.js.map +1 -1
  41. package/lib/module/index.js +7 -0
  42. package/lib/module/index.js.map +1 -1
  43. package/lib/module/ui/components/OxyAuthChooser.js +9 -9
  44. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  45. package/lib/module/ui/components/SurfaceScreen.js +21 -12
  46. package/lib/module/ui/components/SurfaceScreen.js.map +1 -1
  47. package/lib/module/ui/components/authChooser/AccountsMenuView.js +1 -8
  48. package/lib/module/ui/components/authChooser/AccountsMenuView.js.map +1 -1
  49. package/lib/module/ui/components/authChooser/types.js.map +1 -1
  50. package/lib/module/ui/components/signIn/OxyAuthScreen.js +11 -5
  51. package/lib/module/ui/components/signIn/OxyAuthScreen.js.map +1 -1
  52. package/lib/module/ui/components/signIn/OxyCreateAccountPanel.js +231 -0
  53. package/lib/module/ui/components/signIn/OxyCreateAccountPanel.js.map +1 -0
  54. package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js +109 -0
  55. package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -0
  56. package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js +270 -0
  57. package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -0
  58. package/lib/module/ui/components/signIn/OxyRecoverAccountPanel.js +165 -0
  59. package/lib/module/ui/components/signIn/OxyRecoverAccountPanel.js.map +1 -0
  60. package/lib/module/ui/components/signIn/OxySignInPanel.js +47 -32
  61. package/lib/module/ui/components/signIn/OxySignInPanel.js.map +1 -1
  62. package/lib/module/ui/components/signIn/accountFlowParts.js +235 -0
  63. package/lib/module/ui/components/signIn/accountFlowParts.js.map +1 -0
  64. package/lib/module/ui/components/signIn/signInMethods.js +19 -18
  65. package/lib/module/ui/components/signIn/signInMethods.js.map +1 -1
  66. package/lib/module/ui/context/OxyContext.js +4 -4
  67. package/lib/module/ui/context/OxyContext.js.map +1 -1
  68. package/lib/module/ui/context/passkeyFlow.js +21 -0
  69. package/lib/module/ui/context/passkeyFlow.js.map +1 -1
  70. package/lib/module/ui/hooks/useSurfaceFrameWidth.js +24 -0
  71. package/lib/module/ui/hooks/useSurfaceFrameWidth.js.map +1 -0
  72. package/lib/module/ui/screens/EditProfileFieldScreen.js +1 -24
  73. package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
  74. package/lib/module/ui/screens/EditProfileScreen.js +0 -8
  75. package/lib/module/ui/screens/EditProfileScreen.js.map +1 -1
  76. package/lib/module/ui/screens/ManageAccountScreen.js +3 -3
  77. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  78. package/lib/module/ui/utils/accountDeletionHandoff.js +2 -2
  79. package/lib/module/ui/utils/accountDeletionHandoff.js.map +1 -1
  80. package/lib/typescript/commonjs/index.d.ts +8 -0
  81. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts +3 -2
  83. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/ui/components/SurfaceScreen.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts +0 -5
  87. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/ui/components/signIn/OxyAuthScreen.d.ts +7 -1
  89. package/lib/typescript/commonjs/ui/components/signIn/OxyAuthScreen.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/ui/components/signIn/OxyCreateAccountPanel.d.ts +24 -0
  91. package/lib/typescript/commonjs/ui/components/signIn/OxyCreateAccountPanel.d.ts.map +1 -0
  92. package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts +16 -0
  93. package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -0
  94. package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts +21 -0
  95. package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -0
  96. package/lib/typescript/commonjs/ui/components/signIn/OxyRecoverAccountPanel.d.ts +20 -0
  97. package/lib/typescript/commonjs/ui/components/signIn/OxyRecoverAccountPanel.d.ts.map +1 -0
  98. package/lib/typescript/commonjs/ui/components/signIn/OxySignInPanel.d.ts +12 -12
  99. package/lib/typescript/commonjs/ui/components/signIn/OxySignInPanel.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts +60 -0
  101. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts.map +1 -0
  102. package/lib/typescript/commonjs/ui/components/signIn/signInMethods.d.ts +18 -15
  103. package/lib/typescript/commonjs/ui/components/signIn/signInMethods.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +11 -5
  106. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +16 -1
  108. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/ui/hooks/useSurfaceFrameWidth.d.ts +12 -0
  111. package/lib/typescript/commonjs/ui/hooks/useSurfaceFrameWidth.d.ts.map +1 -0
  112. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts +1 -1
  113. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts.map +1 -1
  115. package/lib/typescript/module/index.d.ts +8 -0
  116. package/lib/typescript/module/index.d.ts.map +1 -1
  117. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts +3 -2
  118. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  119. package/lib/typescript/module/ui/components/SurfaceScreen.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
  121. package/lib/typescript/module/ui/components/authChooser/types.d.ts +0 -5
  122. package/lib/typescript/module/ui/components/authChooser/types.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/components/signIn/OxyAuthScreen.d.ts +7 -1
  124. package/lib/typescript/module/ui/components/signIn/OxyAuthScreen.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/components/signIn/OxyCreateAccountPanel.d.ts +24 -0
  126. package/lib/typescript/module/ui/components/signIn/OxyCreateAccountPanel.d.ts.map +1 -0
  127. package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts +16 -0
  128. package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -0
  129. package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts +21 -0
  130. package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -0
  131. package/lib/typescript/module/ui/components/signIn/OxyRecoverAccountPanel.d.ts +20 -0
  132. package/lib/typescript/module/ui/components/signIn/OxyRecoverAccountPanel.d.ts.map +1 -0
  133. package/lib/typescript/module/ui/components/signIn/OxySignInPanel.d.ts +12 -12
  134. package/lib/typescript/module/ui/components/signIn/OxySignInPanel.d.ts.map +1 -1
  135. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts +60 -0
  136. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts.map +1 -0
  137. package/lib/typescript/module/ui/components/signIn/signInMethods.d.ts +18 -15
  138. package/lib/typescript/module/ui/components/signIn/signInMethods.d.ts.map +1 -1
  139. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  140. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +11 -5
  141. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/context/passkeyFlow.d.ts +16 -1
  143. package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -1
  144. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  145. package/lib/typescript/module/ui/hooks/useSurfaceFrameWidth.d.ts +12 -0
  146. package/lib/typescript/module/ui/hooks/useSurfaceFrameWidth.d.ts.map +1 -0
  147. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts +1 -1
  148. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  149. package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts.map +1 -1
  150. package/package.json +4 -4
  151. package/src/index.ts +11 -0
  152. package/src/ui/components/OxyAuthChooser.tsx +9 -9
  153. package/src/ui/components/SurfaceScreen.tsx +19 -11
  154. package/src/ui/components/authChooser/AccountsMenuView.tsx +0 -7
  155. package/src/ui/components/authChooser/types.ts +0 -5
  156. package/src/ui/components/signIn/OxyAuthScreen.tsx +14 -5
  157. package/src/ui/components/signIn/OxyCreateAccountPanel.tsx +207 -0
  158. package/src/ui/components/signIn/OxyDeleteAccountPanel.tsx +106 -0
  159. package/src/ui/components/signIn/OxyLinkCommonsPanel.tsx +222 -0
  160. package/src/ui/components/signIn/OxyRecoverAccountPanel.tsx +154 -0
  161. package/src/ui/components/signIn/OxySignInPanel.tsx +56 -37
  162. package/src/ui/components/signIn/accountFlowParts.tsx +204 -0
  163. package/src/ui/components/signIn/signInMethods.ts +21 -17
  164. package/src/ui/context/OxyContext.tsx +6 -5
  165. package/src/ui/context/__tests__/passkeyFlow.test.ts +59 -1
  166. package/src/ui/context/oxyContextTypes.ts +11 -5
  167. package/src/ui/context/passkeyFlow.ts +28 -0
  168. package/src/ui/hooks/useSurfaceFrameWidth.ts +21 -0
  169. package/src/ui/screens/EditProfileFieldScreen.tsx +1 -27
  170. package/src/ui/screens/EditProfileScreen.tsx +0 -6
  171. package/src/ui/screens/ManageAccountScreen.tsx +3 -3
  172. package/src/ui/utils/accountDeletionHandoff.ts +2 -2
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Deleting a passkey account (ADR 0029 D3), auth.oxy.so's `/delete-account`.
3
+ *
4
+ * A passkey account has no key to sign its deletion with, so the person types
5
+ * the username and asserts one of the account's passkeys — on auth.oxy.so, the
6
+ * one origin that asserts them — over a challenge the API minted for the
7
+ * account. An account with a Commons key is deleted in Commons, with its key.
8
+ * The page must be signed in as the account; the host renders sign-in first.
9
+ */
10
+
11
+ import type React from 'react';
12
+ import { useState } from 'react';
13
+ import { useOxy } from '../../context/OxyContext';
14
+ import { useI18n } from '../../hooks/useI18n';
15
+ import { isPasskeySupported, runAuthenticationCeremony } from '../../../webauthn/passkeyClient';
16
+ import { PASSKEY_UNSUPPORTED_MESSAGE } from '../../context/passkeyFlow';
17
+ import { OxyAuthScreen, OxyAuthScreenHeader } from './OxyAuthScreen';
18
+ import {
19
+ AccountFlowAction,
20
+ AccountFlowErrorLine,
21
+ AccountFlowField,
22
+ AccountFlowNote,
23
+ describeAccountFlowError,
24
+ } from './accountFlowParts';
25
+
26
+ export interface OxyDeleteAccountPanelProps {
27
+ /** The account is gone and this origin signed out of it. */
28
+ onDeleted?: () => void;
29
+ }
30
+
31
+ export const OxyDeleteAccountPanel: React.FC<OxyDeleteAccountPanelProps> = ({ onDeleted }) => {
32
+ const { t } = useI18n();
33
+ const { user, oxyServices, logout } = useOxy();
34
+ const [confirmText, setConfirmText] = useState('');
35
+ const [pending, setPending] = useState(false);
36
+ const [error, setError] = useState<string | null>(null);
37
+ const [deleted, setDeleted] = useState(false);
38
+
39
+ if (deleted) {
40
+ return (
41
+ <OxyAuthScreen>
42
+ <OxyAuthScreenHeader title={t('deleteAccount.passkey.done')} description={t('deleteAccount.passkey.closeWindow')} />
43
+ </OxyAuthScreen>
44
+ );
45
+ }
46
+
47
+ const username = user?.username ?? '';
48
+ if (user?.publicKey) {
49
+ return (
50
+ <OxyAuthScreen>
51
+ <OxyAuthScreenHeader title={t('deleteAccount.handoff.commonsTitle')} description={t('deleteAccount.passkey.commons')} />
52
+ </OxyAuthScreen>
53
+ );
54
+ }
55
+
56
+ // The ceremony opens the browser's passkey prompt, so it starts from the press.
57
+ const remove = () => {
58
+ if (pending) return;
59
+ if (confirmText.trim() !== username) {
60
+ setError(t('deleteAccount.confirmLabel', { username }));
61
+ return;
62
+ }
63
+ setError(null);
64
+ setPending(true);
65
+ (async () => {
66
+ if (!isPasskeySupported()) throw new Error(PASSKEY_UNSUPPORTED_MESSAGE);
67
+ const options = await oxyServices.getAccountDeletionOptions();
68
+ const assertion = await runAuthenticationCeremony(options);
69
+ await oxyServices.deleteAccountWithPasskey(confirmText.trim(), assertion);
70
+ await logout().catch(() => undefined);
71
+ setDeleted(true);
72
+ onDeleted?.();
73
+ })()
74
+ .catch((reason: unknown) => setError(describeAccountFlowError(reason, t)))
75
+ .finally(() => setPending(false));
76
+ };
77
+
78
+ return (
79
+ <OxyAuthScreen>
80
+ <OxyAuthScreenHeader title={t('deleteAccount.title')} description={t('deleteAccount.passkey.subtitle', { username })} />
81
+ <AccountFlowNote>{t('deleteAccount.warning')}</AccountFlowNote>
82
+ <AccountFlowField
83
+ label={t('deleteAccount.confirmLabel', { username })}
84
+ value={confirmText}
85
+ onChange={(value) => {
86
+ setConfirmText(value);
87
+ if (error) setError(null);
88
+ }}
89
+ onSubmit={remove}
90
+ error={null}
91
+ disabled={pending}
92
+ placeholder={username}
93
+ autoComplete="off"
94
+ testID="delete-account-confirm"
95
+ />
96
+ {error ? <AccountFlowErrorLine message={error} /> : null}
97
+ <AccountFlowAction
98
+ label={t('deleteAccount.passkey.action')}
99
+ onPress={remove}
100
+ pending={pending}
101
+ disabled={confirmText.trim() !== username}
102
+ testID="delete-account-passkey"
103
+ />
104
+ </OxyAuthScreen>
105
+ );
106
+ };
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Linking Commons to a passkey account (ADR 0029 D3), auth.oxy.so's
3
+ * `/link-commons`:
4
+ *
5
+ * QR → Commons scans and signs with its key → both screens show the same
6
+ * code → the person confirms here with the passkey → linked
7
+ *
8
+ * Afterwards the account is self-custodied: its root is the key Commons holds,
9
+ * its recovery email is deleted, and its phrase in Commons is how it gets back
10
+ * in. The passkey keeps signing in on the web. The page must be signed in as
11
+ * the account; the host renders sign-in first.
12
+ */
13
+
14
+ import type React from 'react';
15
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+ import { ActivityIndicator, StyleSheet, View } from 'react-native';
17
+ import QRCode from 'react-native-qrcode-svg';
18
+ import { Button } from '@oxy.so/bloom/button';
19
+ import { useTheme } from '@oxy.so/bloom/theme';
20
+ import { Text } from '@oxy.so/bloom/typography';
21
+ import { deriveIdentityLinkCode } from '@oxy.so/core';
22
+ import type { IdentityLinkCreateResponse } from '@oxy.so/contracts';
23
+ import { useOxy } from '../../context/OxyContext';
24
+ import { PASSKEY_UNSUPPORTED_MESSAGE } from '../../context/passkeyFlow';
25
+ import { useI18n } from '../../hooks/useI18n';
26
+ import { isPasskeySupported, runAuthenticationCeremony } from '../../../webauthn/passkeyClient';
27
+ import { OxyAuthScreen, OxyAuthScreenHeader } from './OxyAuthScreen';
28
+ import { AccountFlowAction, AccountFlowErrorLine, AccountFlowNote, describeAccountFlowError } from './accountFlowParts';
29
+
30
+ /** How often the page asks whether Commons has signed. */
31
+ export const IDENTITY_LINK_POLL_MS = 2000;
32
+
33
+ /** High-contrast, un-themed on purpose: scan reliability. */
34
+ const QR_PLATE_BG = '#FFFFFF';
35
+ const QR_FOREGROUND = '#000000';
36
+ const QR_SIZE = 200;
37
+
38
+ type Step =
39
+ | { name: 'opening' }
40
+ | { name: 'qr'; link: IdentityLinkCreateResponse }
41
+ | { name: 'matching'; link: IdentityLinkCreateResponse; code: string }
42
+ | { name: 'expired' }
43
+ | { name: 'done' };
44
+
45
+ export interface OxyLinkCommonsPanelProps {
46
+ /** Commons is linked. */
47
+ onLinked?: () => void;
48
+ }
49
+
50
+ export const OxyLinkCommonsPanel: React.FC<OxyLinkCommonsPanelProps> = ({ onLinked }) => {
51
+ const theme = useTheme();
52
+ const { t } = useI18n();
53
+ const { user, oxyServices } = useOxy();
54
+ const [step, setStep] = useState<Step>({ name: 'opening' });
55
+ const [pending, setPending] = useState(false);
56
+ const [error, setError] = useState<string | null>(null);
57
+ const alreadyLinked = Boolean(user?.publicKey);
58
+
59
+ const open = useCallback(() => {
60
+ setError(null);
61
+ setStep({ name: 'opening' });
62
+ oxyServices
63
+ .createIdentityLink()
64
+ .then((link) => setStep({ name: 'qr', link }))
65
+ .catch((reason: unknown) => {
66
+ setError(describeAccountFlowError(reason, t));
67
+ setStep({ name: 'expired' });
68
+ });
69
+ }, [oxyServices, t]);
70
+
71
+ // One request per visit, opened once the page knows the account has no root.
72
+ const opened = useRef(false);
73
+ useEffect(() => {
74
+ if (alreadyLinked || opened.current) return;
75
+ opened.current = true;
76
+ open();
77
+ }, [alreadyLinked, open]);
78
+
79
+ // While the QR shows, ask whether Commons has signed; a request only lives a
80
+ // few minutes, and an expired one says so instead of waiting forever.
81
+ const link = step.name === 'qr' ? step.link : null;
82
+ useEffect(() => {
83
+ if (!link) return;
84
+ let stopped = false;
85
+ const poll = async () => {
86
+ if (stopped) return;
87
+ if (Date.now() >= link.expiresAt) {
88
+ setStep({ name: 'expired' });
89
+ return;
90
+ }
91
+ try {
92
+ const state = await oxyServices.getIdentityLink(link.linkId);
93
+ if (stopped) return;
94
+ if (state.status === 'signed' && state.publicKey) {
95
+ setStep({ name: 'matching', link, code: deriveIdentityLinkCode(link.linkId, state.publicKey) });
96
+ return;
97
+ }
98
+ if (state.status === 'cancelled') {
99
+ setStep({ name: 'expired' });
100
+ return;
101
+ }
102
+ } catch {
103
+ // A missed poll is retried on the next tick.
104
+ }
105
+ if (!stopped) timer = setTimeout(() => void poll(), IDENTITY_LINK_POLL_MS);
106
+ };
107
+ let timer = setTimeout(() => void poll(), IDENTITY_LINK_POLL_MS);
108
+ return () => {
109
+ stopped = true;
110
+ clearTimeout(timer);
111
+ };
112
+ }, [link, oxyServices]);
113
+
114
+ // The ceremony opens the browser's passkey prompt, so it starts from the press.
115
+ const confirm = (confirmed: IdentityLinkCreateResponse) => {
116
+ if (pending) return;
117
+ setError(null);
118
+ setPending(true);
119
+ (async () => {
120
+ if (!isPasskeySupported()) throw new Error(PASSKEY_UNSUPPORTED_MESSAGE);
121
+ const options = await oxyServices.getIdentityLinkAssertionOptions(confirmed.linkId, confirmed.challenge);
122
+ const assertion = await runAuthenticationCeremony(options);
123
+ await oxyServices.completeIdentityLink(confirmed.linkId, assertion);
124
+ setStep({ name: 'done' });
125
+ onLinked?.();
126
+ })()
127
+ .catch((reason: unknown) => setError(describeAccountFlowError(reason, t)))
128
+ .finally(() => setPending(false));
129
+ };
130
+
131
+ const cancel = (cancelled: IdentityLinkCreateResponse) => {
132
+ void oxyServices.cancelIdentityLink(cancelled.linkId).catch(() => undefined);
133
+ setStep({ name: 'expired' });
134
+ };
135
+
136
+ if (alreadyLinked) {
137
+ return (
138
+ <OxyAuthScreen>
139
+ <OxyAuthScreenHeader title={t('linkCommons.title')} description={t('linkCommons.already')} />
140
+ </OxyAuthScreen>
141
+ );
142
+ }
143
+
144
+ switch (step.name) {
145
+ case 'done':
146
+ return (
147
+ <OxyAuthScreen>
148
+ <OxyAuthScreenHeader title={t('linkCommons.doneTitle')} description={t('linkCommons.done')} />
149
+ </OxyAuthScreen>
150
+ );
151
+ case 'expired':
152
+ return (
153
+ <OxyAuthScreen>
154
+ <OxyAuthScreenHeader title={t('linkCommons.title')} description={t('linkCommons.expired')} />
155
+ {error ? <AccountFlowErrorLine message={error} /> : null}
156
+ <AccountFlowAction label={t('linkCommons.renew')} onPress={open} pending={false} testID="link-commons-renew" />
157
+ </OxyAuthScreen>
158
+ );
159
+ case 'matching':
160
+ return (
161
+ <OxyAuthScreen>
162
+ <OxyAuthScreenHeader title={t('linkCommons.compareTitle')} description={t('linkCommons.compare')} />
163
+ <Text
164
+ accessibilityLabel={step.code.split('').join(' ')}
165
+ style={[styles.code, { color: theme.colors.text }]}
166
+ testID="link-commons-code"
167
+ >
168
+ {`${step.code.slice(0, 3)} ${step.code.slice(3)}`}
169
+ </Text>
170
+ {error ? <AccountFlowErrorLine message={error} /> : null}
171
+ <AccountFlowAction label={t('linkCommons.confirm')} onPress={() => confirm(step.link)} pending={pending} testID="link-commons-confirm" />
172
+ <Button appearance="plain" tone="neutral" size="lg" fullWidth disabled={pending} onPress={() => cancel(step.link)} testID="link-commons-cancel">
173
+ {t('linkCommons.cancel')}
174
+ </Button>
175
+ </OxyAuthScreen>
176
+ );
177
+ case 'qr':
178
+ return (
179
+ <OxyAuthScreen>
180
+ <OxyAuthScreenHeader title={t('linkCommons.title')} description={t('linkCommons.subtitle')} />
181
+ <View style={styles.plateRow}>
182
+ <View style={[styles.plate, { borderColor: theme.colors.border }]} testID="link-commons-qr">
183
+ <QRCode value={step.link.qrPayload} size={QR_SIZE} backgroundColor={QR_PLATE_BG} color={QR_FOREGROUND} />
184
+ </View>
185
+ </View>
186
+ <AccountFlowNote>{t('linkCommons.scan')}</AccountFlowNote>
187
+ <AccountFlowNote>{t('linkCommons.waiting')}</AccountFlowNote>
188
+ <Button appearance="plain" tone="neutral" size="lg" fullWidth onPress={() => cancel(step.link)} testID="link-commons-cancel">
189
+ {t('linkCommons.cancel')}
190
+ </Button>
191
+ </OxyAuthScreen>
192
+ );
193
+ default:
194
+ return (
195
+ <OxyAuthScreen>
196
+ <OxyAuthScreenHeader title={t('linkCommons.title')} description={t('linkCommons.subtitle')} />
197
+ <View style={styles.plateRow}>
198
+ <ActivityIndicator size="large" color={theme.colors.primary} />
199
+ </View>
200
+ </OxyAuthScreen>
201
+ );
202
+ }
203
+ };
204
+
205
+ const styles = StyleSheet.create({
206
+ plateRow: {
207
+ alignItems: 'center',
208
+ },
209
+ plate: {
210
+ padding: 12,
211
+ borderRadius: 16,
212
+ borderWidth: StyleSheet.hairlineWidth,
213
+ backgroundColor: QR_PLATE_BG,
214
+ },
215
+ code: {
216
+ fontSize: 40,
217
+ lineHeight: 48,
218
+ fontWeight: '700',
219
+ letterSpacing: 4,
220
+ textAlign: 'center',
221
+ },
222
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Recovering a passkey account (ADR 0029 D3), auth.oxy.so's `/recover`:
3
+ *
4
+ * username or email → the code sent to the recovery email → new passkey → signed in
5
+ *
6
+ * The first step answers the same whether or not the account exists, so it
7
+ * never says who has one. An account that uses Commons has no recovery email:
8
+ * it recovers in Commons with its phrase, and the first step says so. The new
9
+ * passkey joins the ones the account has; lost ones are removed afterwards in
10
+ * the account's security settings.
11
+ */
12
+
13
+ import type React from 'react';
14
+ import { useState } from 'react';
15
+ import { useTheme } from '@oxy.so/bloom/theme';
16
+ import { DeviceManager } from '@oxy.so/core';
17
+ import { useOxy } from '../../context/OxyContext';
18
+ import { runPasskeyRegisterSignIn } from '../../context/passkeyFlow';
19
+ import { useI18n } from '../../hooks/useI18n';
20
+ import { isPasskeySupported, runRegistrationCeremony } from '../../../webauthn/passkeyClient';
21
+ import { SubtleLink } from '../authChooser/primitives';
22
+ import { OxyAuthScreen, OxyAuthScreenHeader, OxyAuthTerms } from './OxyAuthScreen';
23
+ import {
24
+ AccountFlowAction,
25
+ AccountFlowErrorLine,
26
+ AccountFlowField,
27
+ AccountFlowNote,
28
+ EmailCodeStep,
29
+ describeAccountFlowError,
30
+ isTicketExpired,
31
+ } from './accountFlowParts';
32
+
33
+ type Step =
34
+ | { name: 'entry' }
35
+ | { name: 'code'; verificationId: string }
36
+ | { name: 'passkey'; recoveryTicket: string; username: string | null };
37
+
38
+ export interface OxyRecoverAccountPanelProps {
39
+ /** The account has a new passkey and this origin is signed in as it. */
40
+ onRecovered: () => void;
41
+ /** "Back to sign in". */
42
+ onSignIn: () => void;
43
+ }
44
+
45
+ export const OxyRecoverAccountPanel: React.FC<OxyRecoverAccountPanelProps> = ({ onRecovered, onSignIn }) => {
46
+ const theme = useTheme();
47
+ const { t } = useI18n();
48
+ const { oxyServices, handleWebSession } = useOxy();
49
+ const [step, setStep] = useState<Step>({ name: 'entry' });
50
+ const [identifier, setIdentifier] = useState('');
51
+ const [pending, setPending] = useState(false);
52
+ const [error, setError] = useState<string | null>(null);
53
+
54
+ const run = (work: () => Promise<void>) => {
55
+ if (pending) return;
56
+ setError(null);
57
+ setPending(true);
58
+ work()
59
+ .catch((reason: unknown) => {
60
+ if (isTicketExpired(reason)) setStep({ name: 'entry' });
61
+ setError(describeAccountFlowError(reason, t));
62
+ })
63
+ .finally(() => setPending(false));
64
+ };
65
+
66
+ const sendCode = async () => {
67
+ const { verificationId } = await oxyServices.startEmailVerification({ purpose: 'recovery', identifier: identifier.trim() });
68
+ setStep({ name: 'code', verificationId });
69
+ };
70
+
71
+ const submitIdentifier = () => {
72
+ if (!identifier.trim()) {
73
+ setError(t('recover.identifier.required'));
74
+ return;
75
+ }
76
+ run(sendCode);
77
+ };
78
+
79
+ // The ceremony opens the browser's passkey prompt, so it starts from the press.
80
+ const addPasskey = (recoveryTicket: string) =>
81
+ run(() =>
82
+ runPasskeyRegisterSignIn({
83
+ isSupported: isPasskeySupported,
84
+ getRegisterOptions: () => oxyServices.webauthnRegisterOptions({ recoveryTicket }),
85
+ runCeremony: runRegistrationCeremony,
86
+ registerVerify: (response) =>
87
+ oxyServices.webauthnRegisterVerify(response, {
88
+ recoveryTicket,
89
+ deviceFingerprint: JSON.stringify(DeviceManager.getDeviceFingerprint()),
90
+ }),
91
+ commit: async (session) => {
92
+ await handleWebSession(session);
93
+ onRecovered();
94
+ },
95
+ }),
96
+ );
97
+
98
+ switch (step.name) {
99
+ case 'code':
100
+ return (
101
+ <EmailCodeStep
102
+ description={t('emailCode.sentIfAccount')}
103
+ verificationId={step.verificationId}
104
+ onConfirmed={({ ticket, username }) => {
105
+ setError(null);
106
+ setStep({ name: 'passkey', recoveryTicket: ticket, username });
107
+ }}
108
+ onResend={sendCode}
109
+ back={{ label: t('signin.actions.back'), onPress: () => setStep({ name: 'entry' }) }}
110
+ />
111
+ );
112
+ case 'passkey':
113
+ return (
114
+ <OxyAuthScreen>
115
+ <OxyAuthScreenHeader
116
+ title={t('recover.passkey.title')}
117
+ description={t('recover.passkey.subtitle', { username: step.username ?? '' })}
118
+ />
119
+ {error ? <AccountFlowErrorLine message={error} /> : null}
120
+ <AccountFlowAction
121
+ label={t('recover.passkey.action')}
122
+ onPress={() => addPasskey(step.recoveryTicket)}
123
+ pending={pending}
124
+ testID="recover-create-passkey"
125
+ />
126
+ <OxyAuthTerms />
127
+ </OxyAuthScreen>
128
+ );
129
+ default:
130
+ return (
131
+ <OxyAuthScreen>
132
+ <OxyAuthScreenHeader title={t('recover.title')} description={t('recover.subtitle')} />
133
+ <AccountFlowField
134
+ label={t('recover.identifier.label')}
135
+ value={identifier}
136
+ onChange={(value) => {
137
+ setIdentifier(value);
138
+ if (error) setError(null);
139
+ }}
140
+ onSubmit={submitIdentifier}
141
+ error={error}
142
+ disabled={pending}
143
+ placeholder={t('recover.identifier.placeholder')}
144
+ autoComplete="username"
145
+ testID="recover-identifier"
146
+ />
147
+ <AccountFlowNote>{t('recover.commonsNote')}</AccountFlowNote>
148
+ <AccountFlowAction label={t('signin.actions.continue')} onPress={submitIdentifier} pending={pending} testID="recover-continue" />
149
+ <SubtleLink label={t('recover.backToSignIn')} theme={theme} onPress={onSignIn} testID="back-to-sign-in" />
150
+ <OxyAuthTerms />
151
+ </OxyAuthScreen>
152
+ );
153
+ }
154
+ };
@@ -4,18 +4,18 @@
4
4
  * differs (`host`).
5
5
  *
6
6
  * picker a returning device: "Choose an account", then "Use another account"
7
- * entry the header, the Commons way in, and the way in for someone with no
8
- * account. On auth.oxy.so (`page`) also the username with its
9
- * Continue — a username-first passkey sign-in, which takes a
10
- * platform passkey AND a hardware security key with no resident
11
- * credential (a U2F key such as a Titan) — and "or continue with" the
12
- * discoverable passkey. From `md` that page is Bloom `AuthCard`'s
13
- * split card, with the embedded QR over its photo carousel in the
14
- * right column; below `md` it is "Continue with Oxy" at the top.
15
- * In an app's dialog on the web, "Continue with Oxy" opens that page
16
- * in a window over the app, like "Sign in with Google": the browser's
17
- * session lives on auth.oxy.so, so every Oxy app shares it. On
18
- * native it is "Continue with Oxy" ("Get Commons" without Commons).
7
+ * entry the header, the Commons way in, the passkey, and the way in for
8
+ * someone with no account. On the web, from `md`, the screen is
9
+ * Bloom `AuthCard`'s split card and the Commons way in is the
10
+ * embedded QR over its photo carousel, in the right column (the
11
+ * account dialog grows to that card); below `md` it is "Continue
12
+ * with Oxy" at the top. The passkey belongs to `oxy.so`: on
13
+ * auth.oxy.so (`page`) it runs here — the username with its Continue,
14
+ * a username-first ceremony that takes a hardware security key with
15
+ * no resident credential too, and the discoverable passkey — and in
16
+ * an app's dialog it opens auth.oxy.so's window for that one step, as
17
+ * does "Create account". On native it is "Continue with Oxy" ("Get
18
+ * Commons" without Commons).
19
19
  *
20
20
  * `signInMethods.ts` owns which blocks a platform gets.
21
21
  */
@@ -39,6 +39,7 @@ import { useOxy } from '../../context/OxyContext';
39
39
  import { useAccountDialogSnapshot } from '../../hooks/accountDialogSnapshot';
40
40
  import { useDeviceSwitcher } from '../../hooks/useDeviceSwitcher';
41
41
  import { useI18n } from '../../hooks/useI18n';
42
+ import { useSurfaceFrameWidth } from '../../hooks/useSurfaceFrameWidth';
42
43
  import { getCommonsAcquisitionUrl } from '../../utils/commonsStoreLinks';
43
44
  import { isWebBrowser } from '../../utils/isWebBrowser';
44
45
  import { SubtleLink } from '../authChooser/primitives';
@@ -49,6 +50,9 @@ import { OxyAuthScreen, OxyAuthScreenHeader, OxyAuthSplit, OxyAuthTerms } from '
49
50
  import { RATE_LIMIT_SECONDS, describePasskeyError, isRateLimited } from './passkeyError';
50
51
  import { resolveSignInMethods } from './signInMethods';
51
52
 
53
+ /** Bloom `AuthCard`'s split card width, which the account dialog grows to for it. */
54
+ const SPLIT_WIDTH = 880;
55
+
52
56
  export interface OxySignInPanelProps {
53
57
  /** This screen signed the origin in. */
54
58
  onSignedIn: () => void;
@@ -124,7 +128,8 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
124
128
  void controller?.signInWithOxy();
125
129
  showRequest();
126
130
  };
127
- const signInOnOxy = () => {
131
+ // The passkey is `oxy.so`'s: in an app it runs in auth.oxy.so's window.
132
+ const passkeyOnOxy = () => {
128
133
  void continueOnAuth('signin').then((result) => {
129
134
  if (result.status === 'signed-in') onSignedIn();
130
135
  else if (result.status === 'failed') toast.error(t('signin.errors.failed'));
@@ -189,7 +194,7 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
189
194
  return;
190
195
  case 'failed':
191
196
  // The pair could not be activated as it stands: sign in as it explicitly.
192
- if (methods.passkey && context.handle) reauthenticate(context);
197
+ if (methods.passkey === 'here' && context.handle) reauthenticate(context);
193
198
  else toast.error(t('accountSwitcher.toasts.activateFailed'));
194
199
  return;
195
200
  default:
@@ -198,8 +203,11 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
198
203
  };
199
204
 
200
205
  const showsPicker = pickerAllowed && !showForm && principals.length > 0;
201
- // auth.oxy.so's page is the split card from `md`.
206
+ // The web entry is the split card from `md`. In the account dialog it grows
207
+ // the dialog to that card; below `md` the dialog is a bottom sheet, which a
208
+ // width does not touch.
202
209
  const splits = methods.commons === 'qr';
210
+ useSurfaceFrameWidth(host === 'dialog' && !showsPicker && splits ? SPLIT_WIDTH : null);
203
211
 
204
212
  if (showsPicker) {
205
213
  return (
@@ -241,7 +249,7 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
241
249
  </Text>
242
250
  );
243
251
  // One solid action per screen: the username's Continue when there is one.
244
- const commonsAppearance = methods.passkey ? 'outline' : 'solid';
252
+ const commonsAppearance = methods.passkey === 'here' ? 'outline' : 'solid';
245
253
 
246
254
  const continueWithOxyButton = (
247
255
  <Button
@@ -249,13 +257,28 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
249
257
  tone={commonsAppearance === 'solid' ? 'action' : 'neutral'}
250
258
  size="lg"
251
259
  fullWidth
252
- onPress={methods.commons === 'window' ? signInOnOxy : continueWithOxy}
260
+ onPress={continueWithOxy}
253
261
  testID="continue-with-oxy"
254
262
  >
255
263
  {t('accountSwitcher.continueWithOxy')}
256
264
  </Button>
257
265
  );
258
266
 
267
+ const passkeyButton = (
268
+ <Button
269
+ appearance="outline"
270
+ tone="neutral"
271
+ size="lg"
272
+ fullWidth
273
+ leadingIcon={RiKey2Line}
274
+ disabled={methods.passkey === 'here' && blocked}
275
+ onPress={methods.passkey === 'here' ? () => void runPasskey(undefined, t('signin.errors.failed')) : passkeyOnOxy}
276
+ testID="passkey-sign-in"
277
+ >
278
+ {t('signin.methods.passkey')}
279
+ </Button>
280
+ );
281
+
259
282
  const form = (
260
283
  <OxyAuthScreen className={splits ? 'md:max-w-none' : undefined}>
261
284
  <OxyAuthScreenHeader title={title} description={description} />
@@ -263,7 +286,7 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
263
286
  {/* Below `md` this screen is the phone a QR would be scanned with. */}
264
287
  {splits ? <CssView className="md:hidden">{continueWithOxyButton}</CssView> : null}
265
288
 
266
- {methods.commons === 'continue' || methods.commons === 'window' ? continueWithOxyButton : null}
289
+ {methods.commons === 'continue' ? continueWithOxyButton : null}
267
290
  {methods.commons === 'get-commons' ? (
268
291
  <View style={styles.stack}>
269
292
  <Text style={[styles.note, { color: theme.colors.textSecondary }]}>{t('accountSwitcher.commonsNotInstalled')}</Text>
@@ -273,7 +296,7 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
273
296
  </View>
274
297
  ) : null}
275
298
 
276
- {methods.passkey ? (
299
+ {methods.passkey === 'here' ? (
277
300
  <View style={styles.stack}>
278
301
  <View style={styles.field}>
279
302
  <TextFieldLabel nativeID="username-label">{t('signin.username.label')}</TextFieldLabel>
@@ -314,24 +337,17 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
314
337
  </View>
315
338
  ) : null}
316
339
 
317
- {/* The alternatives to the screen's primary way in, always under it. The
318
- window an app opens on the web is where they all are. */}
319
- {methods.commons === 'window' ? null : <Divider>{t('signin.orContinueWith')}</Divider>}
340
+ {/* The alternatives to the screen's primary way in, always under it. In an
341
+ app's dialog, from `md`, the QR beside it IS the primary way in. */}
342
+ {methods.passkey === 'window' ? (
343
+ <CssView className="md:hidden">
344
+ <Divider>{t('signin.orContinueWith')}</Divider>
345
+ </CssView>
346
+ ) : (
347
+ <Divider>{t('signin.orContinueWith')}</Divider>
348
+ )}
320
349
 
321
- {methods.passkey ? (
322
- <Button
323
- appearance="outline"
324
- tone="neutral"
325
- size="lg"
326
- fullWidth
327
- leadingIcon={RiKey2Line}
328
- disabled={blocked}
329
- onPress={() => void runPasskey(undefined, t('signin.errors.failed'))}
330
- testID="passkey-sign-in"
331
- >
332
- {t('signin.methods.passkey')}
333
- </Button>
334
- ) : methods.commons === 'window' ? null : (
350
+ {methods.passkey === 'none' ? (
335
351
  <Button
336
352
  appearance="outline"
337
353
  tone="neutral"
@@ -343,9 +359,11 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
343
359
  >
344
360
  {t('accountSwitcher.scanQr')}
345
361
  </Button>
362
+ ) : (
363
+ passkeyButton
346
364
  )}
347
365
 
348
- {methods.passkey ? null : noAccount}
366
+ {methods.passkey === 'here' ? null : noAccount}
349
367
  {onRecover ? (
350
368
  <SubtleLink label={t('signin.recoverLink')} theme={theme} onPress={onRecover} testID="recover-link" />
351
369
  ) : null}
@@ -356,6 +374,7 @@ export const OxySignInPanel: React.FC<OxySignInPanelProps> = ({
356
374
  if (!splits) return form;
357
375
  return (
358
376
  <OxyAuthSplit
377
+ bare={host === 'dialog'}
359
378
  aside={
360
379
  <>
361
380
  <AuthMediaCarousel slides={SIGN_IN_SLIDES} style={StyleSheet.absoluteFill} />