@oxy.so/services 6.0.0 → 6.1.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 (125) 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 +1 -2
  5. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
  6. package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js +1 -8
  7. package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js.map +1 -1
  8. package/lib/commonjs/ui/components/authChooser/types.js.map +1 -1
  9. package/lib/commonjs/ui/components/signIn/OxyCreateAccountPanel.js +236 -0
  10. package/lib/commonjs/ui/components/signIn/OxyCreateAccountPanel.js.map +1 -0
  11. package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js +114 -0
  12. package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -0
  13. package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js +275 -0
  14. package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -0
  15. package/lib/commonjs/ui/components/signIn/OxyRecoverAccountPanel.js +170 -0
  16. package/lib/commonjs/ui/components/signIn/OxyRecoverAccountPanel.js.map +1 -0
  17. package/lib/commonjs/ui/components/signIn/accountFlowParts.js +244 -0
  18. package/lib/commonjs/ui/components/signIn/accountFlowParts.js.map +1 -0
  19. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  20. package/lib/commonjs/ui/context/passkeyFlow.js +22 -0
  21. package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -1
  22. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +0 -23
  23. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
  24. package/lib/commonjs/ui/screens/EditProfileScreen.js +0 -8
  25. package/lib/commonjs/ui/screens/EditProfileScreen.js.map +1 -1
  26. package/lib/commonjs/ui/screens/ManageAccountScreen.js +3 -3
  27. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  28. package/lib/commonjs/ui/utils/accountDeletionHandoff.js +2 -2
  29. package/lib/commonjs/ui/utils/accountDeletionHandoff.js.map +1 -1
  30. package/lib/module/index.js +7 -0
  31. package/lib/module/index.js.map +1 -1
  32. package/lib/module/ui/components/OxyAuthChooser.js +2 -3
  33. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  34. package/lib/module/ui/components/authChooser/AccountsMenuView.js +1 -8
  35. package/lib/module/ui/components/authChooser/AccountsMenuView.js.map +1 -1
  36. package/lib/module/ui/components/authChooser/types.js.map +1 -1
  37. package/lib/module/ui/components/signIn/OxyCreateAccountPanel.js +231 -0
  38. package/lib/module/ui/components/signIn/OxyCreateAccountPanel.js.map +1 -0
  39. package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js +109 -0
  40. package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -0
  41. package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js +270 -0
  42. package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -0
  43. package/lib/module/ui/components/signIn/OxyRecoverAccountPanel.js +165 -0
  44. package/lib/module/ui/components/signIn/OxyRecoverAccountPanel.js.map +1 -0
  45. package/lib/module/ui/components/signIn/accountFlowParts.js +235 -0
  46. package/lib/module/ui/components/signIn/accountFlowParts.js.map +1 -0
  47. package/lib/module/ui/context/OxyContext.js.map +1 -1
  48. package/lib/module/ui/context/passkeyFlow.js +21 -0
  49. package/lib/module/ui/context/passkeyFlow.js.map +1 -1
  50. package/lib/module/ui/screens/EditProfileFieldScreen.js +1 -24
  51. package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
  52. package/lib/module/ui/screens/EditProfileScreen.js +0 -8
  53. package/lib/module/ui/screens/EditProfileScreen.js.map +1 -1
  54. package/lib/module/ui/screens/ManageAccountScreen.js +3 -3
  55. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  56. package/lib/module/ui/utils/accountDeletionHandoff.js +2 -2
  57. package/lib/module/ui/utils/accountDeletionHandoff.js.map +1 -1
  58. package/lib/typescript/commonjs/index.d.ts +8 -0
  59. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts +0 -5
  63. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/components/signIn/OxyCreateAccountPanel.d.ts +24 -0
  65. package/lib/typescript/commonjs/ui/components/signIn/OxyCreateAccountPanel.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts +16 -0
  67. package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -0
  68. package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts +21 -0
  69. package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/components/signIn/OxyRecoverAccountPanel.d.ts +20 -0
  71. package/lib/typescript/commonjs/ui/components/signIn/OxyRecoverAccountPanel.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts +60 -0
  73. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +7 -1
  76. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +16 -1
  78. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts +1 -1
  81. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts.map +1 -1
  83. package/lib/typescript/module/index.d.ts +8 -0
  84. package/lib/typescript/module/index.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  86. package/lib/typescript/module/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/components/authChooser/types.d.ts +0 -5
  88. package/lib/typescript/module/ui/components/authChooser/types.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/components/signIn/OxyCreateAccountPanel.d.ts +24 -0
  90. package/lib/typescript/module/ui/components/signIn/OxyCreateAccountPanel.d.ts.map +1 -0
  91. package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts +16 -0
  92. package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -0
  93. package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts +21 -0
  94. package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -0
  95. package/lib/typescript/module/ui/components/signIn/OxyRecoverAccountPanel.d.ts +20 -0
  96. package/lib/typescript/module/ui/components/signIn/OxyRecoverAccountPanel.d.ts.map +1 -0
  97. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts +60 -0
  98. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts.map +1 -0
  99. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  100. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +7 -1
  101. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  102. package/lib/typescript/module/ui/context/passkeyFlow.d.ts +16 -1
  103. package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -1
  104. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  105. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts +1 -1
  106. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  107. package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts.map +1 -1
  108. package/package.json +4 -4
  109. package/src/index.ts +11 -0
  110. package/src/ui/components/OxyAuthChooser.tsx +2 -3
  111. package/src/ui/components/authChooser/AccountsMenuView.tsx +0 -7
  112. package/src/ui/components/authChooser/types.ts +0 -5
  113. package/src/ui/components/signIn/OxyCreateAccountPanel.tsx +207 -0
  114. package/src/ui/components/signIn/OxyDeleteAccountPanel.tsx +106 -0
  115. package/src/ui/components/signIn/OxyLinkCommonsPanel.tsx +222 -0
  116. package/src/ui/components/signIn/OxyRecoverAccountPanel.tsx +154 -0
  117. package/src/ui/components/signIn/accountFlowParts.tsx +204 -0
  118. package/src/ui/context/OxyContext.tsx +2 -1
  119. package/src/ui/context/__tests__/passkeyFlow.test.ts +59 -1
  120. package/src/ui/context/oxyContextTypes.ts +7 -1
  121. package/src/ui/context/passkeyFlow.ts +28 -0
  122. package/src/ui/screens/EditProfileFieldScreen.tsx +1 -27
  123. package/src/ui/screens/EditProfileScreen.tsx +0 -6
  124. package/src/ui/screens/ManageAccountScreen.tsx +3 -3
  125. package/src/ui/utils/accountDeletionHandoff.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/services",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -155,7 +155,7 @@
155
155
  },
156
156
  "dependencies": {
157
157
  "@expo/config-plugins": "57.0.9",
158
- "@oxy.so/contracts": "^2.0.0",
158
+ "@oxy.so/contracts": "^2.2.0",
159
159
  "@simplewebauthn/browser": "^13.3.0",
160
160
  "color": "^4.2.3"
161
161
  },
@@ -164,7 +164,7 @@
164
164
  "@commitlint/cli": "^21.2.1",
165
165
  "@commitlint/config-conventional": "^21.2.0",
166
166
  "@expo/vector-icons": "^15.0.3",
167
- "@oxy.so/core": "1.16.0",
167
+ "@oxy.so/core": "1.18.0",
168
168
  "@react-native-async-storage/async-storage": "^2.0.0",
169
169
  "@react-native-community/netinfo": "^12.0.1",
170
170
  "@react-native/babel-preset": "^0.86.2",
@@ -216,7 +216,7 @@
216
216
  "peerDependencies": {
217
217
  "@expo/vector-icons": "^15.0.3",
218
218
  "@oxy.so/bloom": "^4.26.0",
219
- "@oxy.so/core": "^1.16.0",
219
+ "@oxy.so/core": "^1.18.0",
220
220
  "@react-native-async-storage/async-storage": "^2.0.0",
221
221
  "@react-native-community/netinfo": ">=11.4.1",
222
222
  "@tanstack/query-async-storage-persister": "^5.101",
package/src/index.ts CHANGED
@@ -368,6 +368,17 @@ export { OxySignUpPanel } from './ui/components/signIn/OxySignUpPanel';
368
368
  export type { OxySignUpPanelProps } from './ui/components/signIn/OxySignUpPanel';
369
369
  export { OxyAccountPicker } from './ui/components/signIn/OxyAccountPicker';
370
370
  export type { OxyAccountPickerProps } from './ui/components/signIn/OxyAccountPicker';
371
+ // auth.oxy.so's account pages (ADR 0029 D3): a passkey account is created,
372
+ // recovered through its recovery email, deleted with its passkey, and linked
373
+ // to Commons there.
374
+ export { OxyCreateAccountPanel } from './ui/components/signIn/OxyCreateAccountPanel';
375
+ export type { OxyCreateAccountPanelProps } from './ui/components/signIn/OxyCreateAccountPanel';
376
+ export { OxyRecoverAccountPanel } from './ui/components/signIn/OxyRecoverAccountPanel';
377
+ export type { OxyRecoverAccountPanelProps } from './ui/components/signIn/OxyRecoverAccountPanel';
378
+ export { OxyDeleteAccountPanel } from './ui/components/signIn/OxyDeleteAccountPanel';
379
+ export type { OxyDeleteAccountPanelProps } from './ui/components/signIn/OxyDeleteAccountPanel';
380
+ export { OxyLinkCommonsPanel } from './ui/components/signIn/OxyLinkCommonsPanel';
381
+ export type { OxyLinkCommonsPanelProps } from './ui/components/signIn/OxyLinkCommonsPanel';
371
382
  export {
372
383
  OxyAuthScreen,
373
384
  OxyAuthScreenHeader,
@@ -38,7 +38,7 @@ import { Linking, Platform } from 'react-native';
38
38
  import { toast } from '@oxy.so/bloom/toast';
39
39
  import { surfaces } from '@oxy.so/bloom/surfaces';
40
40
  import { useTheme } from '@oxy.so/bloom/theme';
41
- import { AUTH_WEB_ORIGIN, getNormalizedUserHandle, type OxyAuthScreen, type User } from '@oxy.so/core';
41
+ import { getNormalizedUserHandle, type OxyAuthScreen, type User } from '@oxy.so/core';
42
42
  import { useQueryClient } from '@tanstack/react-query';
43
43
  import { useOxy } from '../context/OxyContext';
44
44
  import { useDeviceSwitcher } from '../hooks/useDeviceSwitcher';
@@ -419,7 +419,6 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
419
419
  onSignOut: () => {
420
420
  void handleSignOut();
421
421
  },
422
- onOpenIdentity: passkeyMode === 'hub' ? () => openUrl(`${AUTH_WEB_ORIGIN}/identity`) : undefined,
423
422
  customItems: (consumerHooks?.menuItems ?? []).map((item) => ({
424
423
  ...item,
425
424
  onPress: () => {
@@ -428,7 +427,7 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
428
427
  },
429
428
  })),
430
429
  };
431
- }, [consumerHooks, onComplete, showBottomSheet, handleSignOut, openExternal, passkeyMode]);
430
+ }, [consumerHooks, onComplete, showBottomSheet, handleSignOut, openExternal]);
432
431
 
433
432
  if (!controller) {
434
433
  return null;
@@ -504,13 +504,6 @@ const AccountsMenuView: React.FC<AccountsMenuViewProps> = ({
504
504
  title={t('accountMenu.settings')}
505
505
  onPress={menu.onOpenSettings}
506
506
  />
507
- {menu.onOpenIdentity ? (
508
- <SettingsListItem
509
- icon={<MenuIcon name="shield-account-outline" theme={theme} />}
510
- title={t('accountMenu.identity')}
511
- onPress={menu.onOpenIdentity}
512
- />
513
- ) : null}
514
507
  <SettingsListItem
515
508
  icon={<MenuIcon name="help-circle-outline" theme={theme} />}
516
509
  title={t('accountMenu.help')}
@@ -95,11 +95,6 @@ export interface AccountsMenuActions {
95
95
  onPrivacy: () => void;
96
96
  onTerms: () => void;
97
97
  onSignOut: () => void;
98
- /**
99
- * Open the person's identity at auth.oxy.so/identity (recovery phrase,
100
- * recovery, account deletion). Web only — `undefined` hides the row.
101
- */
102
- onOpenIdentity?: () => void;
103
98
  customItems: readonly {
104
99
  key: string;
105
100
  label: string;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Creating an Oxy account on the web (ADR 0029 D3), auth.oxy.so's `/signup`:
3
+ *
4
+ * username → recovery email → the code sent to it → passkey → signed in
5
+ *
6
+ * A web account is a username, a passkey and a recovery email — no password, no
7
+ * phrase, no key. The email is confirmed before the account exists, and is
8
+ * used only to get back in (`OxyRecoverAccountPanel`). Commons stays the way to
9
+ * hold your own key: the first step says so, and linking Commons later makes
10
+ * the account self-custodied and deletes the email.
11
+ *
12
+ * Only on auth.oxy.so: the passkey belongs to that origin (RP ID `oxy.so`,
13
+ * asserted only there), and an app's dialog opens this page in a window
14
+ * (`OxySignUpPanel`'s `onCreateOnWeb`).
15
+ */
16
+
17
+ import type React from 'react';
18
+ import { useState } from 'react';
19
+ import { Linking, Platform } from 'react-native';
20
+ import { useTheme } from '@oxy.so/bloom/theme';
21
+ import { toast } from '@oxy.so/bloom/toast';
22
+ import { DeviceManager } from '@oxy.so/core';
23
+ import { emailAddressSchema } from '@oxy.so/contracts';
24
+ import { useOxy } from '../../context/OxyContext';
25
+ import { runPasskeyRegisterSignIn } from '../../context/passkeyFlow';
26
+ import { useI18n } from '../../hooks/useI18n';
27
+ import { getCommonsAcquisitionUrl } from '../../utils/commonsStoreLinks';
28
+ import { isPasskeySupported, runRegistrationCeremony } from '../../../webauthn/passkeyClient';
29
+ import { SubtleLink } from '../authChooser/primitives';
30
+ import { OxyAuthScreen, OxyAuthScreenHeader, OxyAuthTerms } from './OxyAuthScreen';
31
+ import {
32
+ AccountFlowAction,
33
+ AccountFlowErrorLine,
34
+ AccountFlowField,
35
+ EmailCodeStep,
36
+ describeAccountFlowError,
37
+ isTicketExpired,
38
+ } from './accountFlowParts';
39
+
40
+ type Step =
41
+ | { name: 'username' }
42
+ | { name: 'email' }
43
+ | { name: 'code'; verificationId: string }
44
+ | { name: 'passkey'; emailTicket: string };
45
+
46
+ export interface OxyCreateAccountPanelProps {
47
+ /** The account exists and this origin is signed in as it. */
48
+ onSignedIn: () => void;
49
+ /** "Already have an account? Sign in". */
50
+ onSignIn: () => void;
51
+ }
52
+
53
+ export const OxyCreateAccountPanel: React.FC<OxyCreateAccountPanelProps> = ({ onSignedIn, onSignIn }) => {
54
+ const theme = useTheme();
55
+ const { t } = useI18n();
56
+ const { oxyServices, handleWebSession } = useOxy();
57
+ const [step, setStep] = useState<Step>({ name: 'username' });
58
+ const [username, setUsername] = useState('');
59
+ const [email, setEmail] = useState('');
60
+ const [pending, setPending] = useState(false);
61
+ const [error, setError] = useState<string | null>(null);
62
+
63
+ const run = (work: () => Promise<void>) => {
64
+ if (pending) return;
65
+ setError(null);
66
+ setPending(true);
67
+ work()
68
+ .catch((reason: unknown) => {
69
+ if (isTicketExpired(reason)) setStep({ name: 'email' });
70
+ setError(describeAccountFlowError(reason, t));
71
+ })
72
+ .finally(() => setPending(false));
73
+ };
74
+
75
+ const submitUsername = () => {
76
+ const handle = username.trim();
77
+ if (!handle) {
78
+ setError(t('signup.username.required'));
79
+ return;
80
+ }
81
+ run(async () => {
82
+ const { available } = await oxyServices.checkUsernameAvailability(handle);
83
+ if (!available) throw new Error(t('signup.username.taken'));
84
+ setStep({ name: 'email' });
85
+ });
86
+ };
87
+
88
+ const sendCode = async (address: string) => {
89
+ const { verificationId } = await oxyServices.startEmailVerification({ purpose: 'signup', email: address });
90
+ setStep({ name: 'code', verificationId });
91
+ };
92
+
93
+ const submitEmail = () => {
94
+ const parsed = emailAddressSchema.safeParse(email);
95
+ if (!parsed.success) {
96
+ setError(t('signup.email.invalid'));
97
+ return;
98
+ }
99
+ setEmail(parsed.data);
100
+ run(() => sendCode(parsed.data));
101
+ };
102
+
103
+ // The ceremony opens the browser's passkey prompt, so it starts from the press.
104
+ const createPasskey = (emailTicket: string) =>
105
+ run(() =>
106
+ runPasskeyRegisterSignIn({
107
+ isSupported: isPasskeySupported,
108
+ getRegisterOptions: () => oxyServices.webauthnRegisterOptions({ username: username.trim() }),
109
+ runCeremony: runRegistrationCeremony,
110
+ registerVerify: (response) =>
111
+ oxyServices.webauthnRegisterVerify(response, {
112
+ username: username.trim(),
113
+ email,
114
+ emailTicket,
115
+ deviceFingerprint: JSON.stringify(DeviceManager.getDeviceFingerprint()),
116
+ }),
117
+ commit: async (session) => {
118
+ await handleWebSession(session);
119
+ onSignedIn();
120
+ },
121
+ }),
122
+ );
123
+
124
+ const getCommons = () => {
125
+ Promise.resolve()
126
+ .then(() => Linking.openURL(getCommonsAcquisitionUrl(Platform.OS)))
127
+ .catch(() => toast.error(t('accountSwitcher.linkOpenFailed')));
128
+ };
129
+
130
+ switch (step.name) {
131
+ case 'code':
132
+ return (
133
+ <EmailCodeStep
134
+ description={t('emailCode.sentTo', { email })}
135
+ verificationId={step.verificationId}
136
+ onConfirmed={({ ticket }) => {
137
+ setError(null);
138
+ setStep({ name: 'passkey', emailTicket: ticket });
139
+ }}
140
+ onResend={() => sendCode(email)}
141
+ back={{ label: t('emailCode.changeEmail'), onPress: () => setStep({ name: 'email' }) }}
142
+ />
143
+ );
144
+ case 'passkey':
145
+ return (
146
+ <OxyAuthScreen>
147
+ <OxyAuthScreenHeader title={t('signup.passkey.title')} description={t('signup.passkey.subtitle')} />
148
+ {error ? <AccountFlowErrorLine message={error} /> : null}
149
+ <AccountFlowAction
150
+ label={t('signup.passkey.action')}
151
+ onPress={() => createPasskey(step.emailTicket)}
152
+ pending={pending}
153
+ testID="signup-create-passkey"
154
+ />
155
+ <OxyAuthTerms />
156
+ </OxyAuthScreen>
157
+ );
158
+ case 'email':
159
+ return (
160
+ <OxyAuthScreen>
161
+ <OxyAuthScreenHeader title={t('signup.email.title')} description={t('signup.email.subtitle')} />
162
+ <AccountFlowField
163
+ label={t('signup.email.label')}
164
+ value={email}
165
+ onChange={(value) => {
166
+ setEmail(value);
167
+ if (error) setError(null);
168
+ }}
169
+ onSubmit={submitEmail}
170
+ error={error}
171
+ disabled={pending}
172
+ placeholder={t('signup.email.placeholder')}
173
+ autoComplete="email"
174
+ keyboardType="email-address"
175
+ testID="signup-email"
176
+ />
177
+ <AccountFlowAction label={t('signin.actions.continue')} onPress={submitEmail} pending={pending} testID="signup-email-continue" />
178
+ <SubtleLink label={t('signin.actions.back')} theme={theme} onPress={() => setStep({ name: 'username' })} testID="signup-back" />
179
+ <OxyAuthTerms />
180
+ </OxyAuthScreen>
181
+ );
182
+ default:
183
+ return (
184
+ <OxyAuthScreen>
185
+ <OxyAuthScreenHeader title={t('signup.title')} description={t('signup.webSubtitle')} />
186
+ <AccountFlowField
187
+ label={t('signup.username.label')}
188
+ value={username}
189
+ onChange={(value) => {
190
+ setUsername(value);
191
+ if (error) setError(null);
192
+ }}
193
+ onSubmit={submitUsername}
194
+ error={error}
195
+ disabled={pending}
196
+ placeholder={t('signup.username.placeholder')}
197
+ autoComplete="username"
198
+ testID="signup-username"
199
+ />
200
+ <AccountFlowAction label={t('signin.actions.continue')} onPress={submitUsername} pending={pending} testID="signup-username-continue" />
201
+ <SubtleLink label={t('signup.commonsInstead')} theme={theme} onPress={getCommons} testID="signup-commons-instead" />
202
+ <SubtleLink label={t('signup.backToSignInLink')} theme={theme} onPress={onSignIn} testID="back-to-sign-in" />
203
+ <OxyAuthTerms />
204
+ </OxyAuthScreen>
205
+ );
206
+ }
207
+ };
@@ -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
+ });