@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.
- package/README.md +1 -1
- package/lib/commonjs/index.js +32 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAuthChooser.js +1 -2
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js +1 -8
- package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js.map +1 -1
- package/lib/commonjs/ui/components/authChooser/types.js.map +1 -1
- package/lib/commonjs/ui/components/signIn/OxyCreateAccountPanel.js +236 -0
- package/lib/commonjs/ui/components/signIn/OxyCreateAccountPanel.js.map +1 -0
- package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js +114 -0
- package/lib/commonjs/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -0
- package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js +275 -0
- package/lib/commonjs/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -0
- package/lib/commonjs/ui/components/signIn/OxyRecoverAccountPanel.js +170 -0
- package/lib/commonjs/ui/components/signIn/OxyRecoverAccountPanel.js.map +1 -0
- package/lib/commonjs/ui/components/signIn/accountFlowParts.js +244 -0
- package/lib/commonjs/ui/components/signIn/accountFlowParts.js.map +1 -0
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/passkeyFlow.js +22 -0
- package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -1
- package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +0 -23
- package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/EditProfileScreen.js +0 -8
- package/lib/commonjs/ui/screens/EditProfileScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/ManageAccountScreen.js +3 -3
- package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/accountDeletionHandoff.js +2 -2
- package/lib/commonjs/ui/utils/accountDeletionHandoff.js.map +1 -1
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/OxyAuthChooser.js +2 -3
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/module/ui/components/authChooser/AccountsMenuView.js +1 -8
- package/lib/module/ui/components/authChooser/AccountsMenuView.js.map +1 -1
- package/lib/module/ui/components/authChooser/types.js.map +1 -1
- package/lib/module/ui/components/signIn/OxyCreateAccountPanel.js +231 -0
- package/lib/module/ui/components/signIn/OxyCreateAccountPanel.js.map +1 -0
- package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js +109 -0
- package/lib/module/ui/components/signIn/OxyDeleteAccountPanel.js.map +1 -0
- package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js +270 -0
- package/lib/module/ui/components/signIn/OxyLinkCommonsPanel.js.map +1 -0
- package/lib/module/ui/components/signIn/OxyRecoverAccountPanel.js +165 -0
- package/lib/module/ui/components/signIn/OxyRecoverAccountPanel.js.map +1 -0
- package/lib/module/ui/components/signIn/accountFlowParts.js +235 -0
- package/lib/module/ui/components/signIn/accountFlowParts.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/passkeyFlow.js +21 -0
- package/lib/module/ui/context/passkeyFlow.js.map +1 -1
- package/lib/module/ui/screens/EditProfileFieldScreen.js +1 -24
- package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
- package/lib/module/ui/screens/EditProfileScreen.js +0 -8
- package/lib/module/ui/screens/EditProfileScreen.js.map +1 -1
- package/lib/module/ui/screens/ManageAccountScreen.js +3 -3
- package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/module/ui/utils/accountDeletionHandoff.js +2 -2
- package/lib/module/ui/utils/accountDeletionHandoff.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +8 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts +0 -5
- package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/signIn/OxyCreateAccountPanel.d.ts +24 -0
- package/lib/typescript/commonjs/ui/components/signIn/OxyCreateAccountPanel.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts +16 -0
- package/lib/typescript/commonjs/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts +21 -0
- package/lib/typescript/commonjs/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/signIn/OxyRecoverAccountPanel.d.ts +20 -0
- package/lib/typescript/commonjs/ui/components/signIn/OxyRecoverAccountPanel.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts +60 -0
- package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +7 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +16 -1
- package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +8 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/authChooser/types.d.ts +0 -5
- package/lib/typescript/module/ui/components/authChooser/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/signIn/OxyCreateAccountPanel.d.ts +24 -0
- package/lib/typescript/module/ui/components/signIn/OxyCreateAccountPanel.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts +16 -0
- package/lib/typescript/module/ui/components/signIn/OxyDeleteAccountPanel.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts +21 -0
- package/lib/typescript/module/ui/components/signIn/OxyLinkCommonsPanel.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/signIn/OxyRecoverAccountPanel.d.ts +20 -0
- package/lib/typescript/module/ui/components/signIn/OxyRecoverAccountPanel.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts +60 -0
- package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +7 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/passkeyFlow.d.ts +16 -1
- package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts +1 -1
- package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +11 -0
- package/src/ui/components/OxyAuthChooser.tsx +2 -3
- package/src/ui/components/authChooser/AccountsMenuView.tsx +0 -7
- package/src/ui/components/authChooser/types.ts +0 -5
- package/src/ui/components/signIn/OxyCreateAccountPanel.tsx +207 -0
- package/src/ui/components/signIn/OxyDeleteAccountPanel.tsx +106 -0
- package/src/ui/components/signIn/OxyLinkCommonsPanel.tsx +222 -0
- package/src/ui/components/signIn/OxyRecoverAccountPanel.tsx +154 -0
- package/src/ui/components/signIn/accountFlowParts.tsx +204 -0
- package/src/ui/context/OxyContext.tsx +2 -1
- package/src/ui/context/__tests__/passkeyFlow.test.ts +59 -1
- package/src/ui/context/oxyContextTypes.ts +7 -1
- package/src/ui/context/passkeyFlow.ts +28 -0
- package/src/ui/screens/EditProfileFieldScreen.tsx +1 -27
- package/src/ui/screens/EditProfileScreen.tsx +0 -6
- package/src/ui/screens/ManageAccountScreen.tsx +3 -3
- package/src/ui/utils/accountDeletionHandoff.ts +2 -2
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pieces auth.oxy.so's account pages share (ADR 0029 D3): creating a
|
|
3
|
+
* passkey account, recovering one, deleting one. Each is a page of the IdP
|
|
4
|
+
* built from the same sign-in shell as `OxySignInPanel`, so they read as one
|
|
5
|
+
* product; the passkey belongs to auth.oxy.so, so none of them runs anywhere
|
|
6
|
+
* else.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type React from 'react';
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
import { StyleSheet, View } from 'react-native';
|
|
12
|
+
import { Button } from '@oxy.so/bloom/button';
|
|
13
|
+
import { useTheme } from '@oxy.so/bloom/theme';
|
|
14
|
+
import { TextField, TextFieldHint, TextFieldInput, TextFieldLabel } from '@oxy.so/bloom/text-field';
|
|
15
|
+
import { Text } from '@oxy.so/bloom/typography';
|
|
16
|
+
import { EMAIL_CODE_LENGTH, EMAIL_VERIFICATION_ERROR_CODES, type EmailVerificationConfirmResponse } from '@oxy.so/contracts';
|
|
17
|
+
import { useOxy } from '../../context/OxyContext';
|
|
18
|
+
import { useI18n } from '../../hooks/useI18n';
|
|
19
|
+
import type { Translate } from '../authChooser/types';
|
|
20
|
+
import { SubtleLink } from '../authChooser/primitives';
|
|
21
|
+
import { OxyAuthScreen, OxyAuthScreenHeader } from './OxyAuthScreen';
|
|
22
|
+
import { describePasskeyError, isRateLimited } from './passkeyError';
|
|
23
|
+
|
|
24
|
+
/** The API error code a thrown SDK error carries (`error.code`), if any. */
|
|
25
|
+
function errorCode(error: unknown): string | undefined {
|
|
26
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
27
|
+
return typeof code === 'string' ? code : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A failure of an account page, in the person's language. */
|
|
31
|
+
export function describeAccountFlowError(error: unknown, t: Translate): string {
|
|
32
|
+
switch (errorCode(error)) {
|
|
33
|
+
case EMAIL_VERIFICATION_ERROR_CODES.codeInvalid:
|
|
34
|
+
return t('emailCode.errors.codeInvalid');
|
|
35
|
+
case EMAIL_VERIFICATION_ERROR_CODES.tooManyAttempts:
|
|
36
|
+
return t('emailCode.errors.tooManyAttempts');
|
|
37
|
+
case EMAIL_VERIFICATION_ERROR_CODES.ticketInvalid:
|
|
38
|
+
case EMAIL_VERIFICATION_ERROR_CODES.ticketRequired:
|
|
39
|
+
return t('emailCode.errors.expired');
|
|
40
|
+
case EMAIL_VERIFICATION_ERROR_CODES.unavailable:
|
|
41
|
+
return t('emailCode.errors.unavailable');
|
|
42
|
+
default:
|
|
43
|
+
if (isRateLimited(error)) return t('emailCode.errors.rateLimited');
|
|
44
|
+
return describePasskeyError(error, t);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether the confirmation this flow holds is gone and it must start again. */
|
|
49
|
+
export function isTicketExpired(error: unknown): boolean {
|
|
50
|
+
const code = errorCode(error);
|
|
51
|
+
return code === EMAIL_VERIFICATION_ERROR_CODES.ticketInvalid || code === EMAIL_VERIFICATION_ERROR_CODES.ticketRequired;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One labelled input, the way the sign-in screen draws its username. */
|
|
55
|
+
export const AccountFlowField: React.FC<{
|
|
56
|
+
label: string;
|
|
57
|
+
value: string;
|
|
58
|
+
onChange: (value: string) => void;
|
|
59
|
+
onSubmit: () => void;
|
|
60
|
+
error: string | null;
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
placeholder?: string;
|
|
63
|
+
autoComplete?: 'username' | 'email' | 'one-time-code' | 'off';
|
|
64
|
+
keyboardType?: 'default' | 'email-address' | 'number-pad';
|
|
65
|
+
maxLength?: number;
|
|
66
|
+
testID: string;
|
|
67
|
+
}> = ({ label, value, onChange, onSubmit, error, disabled, placeholder, autoComplete, keyboardType, maxLength, testID }) => (
|
|
68
|
+
<View style={styles.field}>
|
|
69
|
+
<TextFieldLabel>{label}</TextFieldLabel>
|
|
70
|
+
<TextField invalid={error !== null} disabled={disabled} radius={999} style={styles.input}>
|
|
71
|
+
<TextFieldInput
|
|
72
|
+
testID={testID}
|
|
73
|
+
label={label}
|
|
74
|
+
value={value}
|
|
75
|
+
onValueChange={onChange}
|
|
76
|
+
placeholder={placeholder}
|
|
77
|
+
autoComplete={autoComplete}
|
|
78
|
+
keyboardType={keyboardType}
|
|
79
|
+
maxLength={maxLength}
|
|
80
|
+
autoCapitalize="none"
|
|
81
|
+
autoCorrect={false}
|
|
82
|
+
autoFocus
|
|
83
|
+
returnKeyType="go"
|
|
84
|
+
onSubmitEditing={onSubmit}
|
|
85
|
+
aria-required
|
|
86
|
+
/>
|
|
87
|
+
</TextField>
|
|
88
|
+
{error ? <TextFieldHint invalid>{error}</TextFieldHint> : null}
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
/** The screen's one primary action. */
|
|
93
|
+
export const AccountFlowAction: React.FC<{
|
|
94
|
+
label: string;
|
|
95
|
+
onPress: () => void;
|
|
96
|
+
pending: boolean;
|
|
97
|
+
disabled?: boolean;
|
|
98
|
+
testID: string;
|
|
99
|
+
}> = ({ label, onPress, pending, disabled, testID }) => (
|
|
100
|
+
<Button appearance="solid" tone="action" size="lg" fullWidth loading={pending} disabled={pending || disabled} onPress={onPress} testID={testID}>
|
|
101
|
+
{label}
|
|
102
|
+
</Button>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/** A line of body copy under the header. */
|
|
106
|
+
export const AccountFlowNote: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
107
|
+
const theme = useTheme();
|
|
108
|
+
return <Text style={[styles.note, { color: theme.colors.textSecondary }]}>{children}</Text>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Why a step failed, where it happened. */
|
|
112
|
+
export const AccountFlowErrorLine: React.FC<{ message: string }> = ({ message }) => {
|
|
113
|
+
const theme = useTheme();
|
|
114
|
+
return (
|
|
115
|
+
<Text accessibilityRole="alert" style={[styles.note, { color: theme.colors.error }]}>
|
|
116
|
+
{message}
|
|
117
|
+
</Text>
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export interface EmailCodeStepProps {
|
|
122
|
+
/** What was sent, and where (or, for a recovery, where it may have gone). */
|
|
123
|
+
description: string;
|
|
124
|
+
verificationId: string;
|
|
125
|
+
onConfirmed: (confirmed: EmailVerificationConfirmResponse) => void;
|
|
126
|
+
/** Send a new code; resolves once it is on its way. */
|
|
127
|
+
onResend: () => Promise<void>;
|
|
128
|
+
/** "Use another email" / "Back" — the step before. */
|
|
129
|
+
back: { label: string; onPress: () => void };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** "Check your email": the 6-digit code a verification sent. */
|
|
133
|
+
export const EmailCodeStep: React.FC<EmailCodeStepProps> = ({ description, verificationId, onConfirmed, onResend, back }) => {
|
|
134
|
+
const theme = useTheme();
|
|
135
|
+
const { t } = useI18n();
|
|
136
|
+
const { oxyServices } = useOxy();
|
|
137
|
+
const [code, setCode] = useState('');
|
|
138
|
+
const [pending, setPending] = useState(false);
|
|
139
|
+
const [error, setError] = useState<string | null>(null);
|
|
140
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
141
|
+
|
|
142
|
+
const confirm = () => {
|
|
143
|
+
const digits = code.replace(/\D/g, '');
|
|
144
|
+
if (digits.length !== EMAIL_CODE_LENGTH || pending) {
|
|
145
|
+
setError(t('emailCode.errors.codeInvalid'));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
setError(null);
|
|
149
|
+
setPending(true);
|
|
150
|
+
oxyServices
|
|
151
|
+
.confirmEmailVerification(verificationId, digits)
|
|
152
|
+
.then(onConfirmed)
|
|
153
|
+
.catch((reason: unknown) => setError(describeAccountFlowError(reason, t)))
|
|
154
|
+
.finally(() => setPending(false));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const resend = () => {
|
|
158
|
+
setError(null);
|
|
159
|
+
setNotice(null);
|
|
160
|
+
setCode('');
|
|
161
|
+
onResend()
|
|
162
|
+
.then(() => setNotice(t('emailCode.resent')))
|
|
163
|
+
.catch((reason: unknown) => setError(describeAccountFlowError(reason, t)));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<OxyAuthScreen>
|
|
168
|
+
<OxyAuthScreenHeader title={t('emailCode.title')} description={description} />
|
|
169
|
+
<AccountFlowField
|
|
170
|
+
label={t('emailCode.label')}
|
|
171
|
+
value={code}
|
|
172
|
+
onChange={(value) => {
|
|
173
|
+
setCode(value);
|
|
174
|
+
if (error) setError(null);
|
|
175
|
+
}}
|
|
176
|
+
onSubmit={confirm}
|
|
177
|
+
error={error}
|
|
178
|
+
disabled={pending}
|
|
179
|
+
placeholder="000000"
|
|
180
|
+
autoComplete="one-time-code"
|
|
181
|
+
keyboardType="number-pad"
|
|
182
|
+
maxLength={EMAIL_CODE_LENGTH}
|
|
183
|
+
testID="email-code"
|
|
184
|
+
/>
|
|
185
|
+
{notice ? <Text style={[styles.note, { color: theme.colors.textSecondary }]}>{notice}</Text> : null}
|
|
186
|
+
<AccountFlowAction label={t('signin.actions.continue')} onPress={confirm} pending={pending} testID="email-code-continue" />
|
|
187
|
+
<SubtleLink label={t('emailCode.resend')} theme={theme} onPress={resend} disabled={pending} testID="email-code-resend" />
|
|
188
|
+
<SubtleLink label={back.label} theme={theme} onPress={back.onPress} disabled={pending} testID="email-code-back" />
|
|
189
|
+
</OxyAuthScreen>
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const styles = StyleSheet.create({
|
|
194
|
+
field: {
|
|
195
|
+
gap: 6,
|
|
196
|
+
},
|
|
197
|
+
input: {
|
|
198
|
+
height: 40,
|
|
199
|
+
},
|
|
200
|
+
note: {
|
|
201
|
+
fontSize: 16,
|
|
202
|
+
lineHeight: 24,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from 'react';
|
|
11
11
|
import { Linking, Platform } from 'react-native';
|
|
12
12
|
import { DeviceManager, OxyServices, oxyClient, type OxyAuthScreen } from '@oxy.so/core';
|
|
13
|
+
import type { LoginSessionResult } from '@oxy.so/contracts';
|
|
13
14
|
import type {
|
|
14
15
|
User,
|
|
15
16
|
SessionLoginResponse,
|
|
@@ -720,7 +721,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
720
721
|
// Public `handleWebSession`: commit a session from the QR device flow. It is a
|
|
721
722
|
// deliberate sign-in on THIS device, so it activates the account.
|
|
722
723
|
const handleWebSession = useCallback(
|
|
723
|
-
async (session: SessionLoginResponse): Promise<void> => {
|
|
724
|
+
async (session: SessionLoginResponse | LoginSessionResult): Promise<void> => {
|
|
724
725
|
if (!session?.user || !session?.sessionId || !session.accessToken) {
|
|
725
726
|
throw new Error('Session response did not include a usable session');
|
|
726
727
|
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import type { LoginSessionResult } from '@oxy.so/contracts';
|
|
13
13
|
import {
|
|
14
14
|
runPasskeyLogin,
|
|
15
|
-
|
|
15
|
+
runPasskeyAdd,
|
|
16
|
+
runPasskeyRegisterSignIn,
|
|
16
17
|
PASSKEY_UNSUPPORTED_MESSAGE,
|
|
17
18
|
type RunPasskeyLoginDeps,
|
|
18
19
|
type RunPasskeyAddDeps,
|
|
@@ -179,3 +180,60 @@ describe('runPasskeyAdd', () => {
|
|
|
179
180
|
expect(deps.onLinked).not.toHaveBeenCalled();
|
|
180
181
|
});
|
|
181
182
|
});
|
|
183
|
+
|
|
184
|
+
describe('runPasskeyRegisterSignIn', () => {
|
|
185
|
+
it('runs options → ceremony → verify → commit, and commits the session it minted', async () => {
|
|
186
|
+
const order: string[] = [];
|
|
187
|
+
const commit = jest.fn(async (result: LoginSessionResult) => {
|
|
188
|
+
order.push('commit');
|
|
189
|
+
expect(result).toBe(sessionResult);
|
|
190
|
+
});
|
|
191
|
+
await runPasskeyRegisterSignIn({
|
|
192
|
+
isSupported: () => true,
|
|
193
|
+
getRegisterOptions: async () => {
|
|
194
|
+
order.push('register-options');
|
|
195
|
+
return { challenge: 'c' };
|
|
196
|
+
},
|
|
197
|
+
runCeremony: async (options) => {
|
|
198
|
+
order.push('ceremony');
|
|
199
|
+
expect(options).toEqual({ challenge: 'c' });
|
|
200
|
+
return { id: 'cred' };
|
|
201
|
+
},
|
|
202
|
+
registerVerify: async (response) => {
|
|
203
|
+
order.push('verify');
|
|
204
|
+
expect(response).toEqual({ id: 'cred' });
|
|
205
|
+
return sessionResult;
|
|
206
|
+
},
|
|
207
|
+
commit,
|
|
208
|
+
});
|
|
209
|
+
expect(order).toEqual(['register-options', 'ceremony', 'verify', 'commit']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('refuses a verify that linked instead of signing in', async () => {
|
|
213
|
+
const commit = jest.fn();
|
|
214
|
+
await expect(
|
|
215
|
+
runPasskeyRegisterSignIn({
|
|
216
|
+
isSupported: () => true,
|
|
217
|
+
getRegisterOptions: async () => ({}),
|
|
218
|
+
runCeremony: async () => ({}),
|
|
219
|
+
registerVerify: async () => linkResult,
|
|
220
|
+
commit,
|
|
221
|
+
}),
|
|
222
|
+
).rejects.toThrow('without signing in');
|
|
223
|
+
expect(commit).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('fails before any ceremony where passkeys are unsupported', async () => {
|
|
227
|
+
const getRegisterOptions = jest.fn();
|
|
228
|
+
await expect(
|
|
229
|
+
runPasskeyRegisterSignIn({
|
|
230
|
+
isSupported: () => false,
|
|
231
|
+
getRegisterOptions,
|
|
232
|
+
runCeremony: jest.fn(),
|
|
233
|
+
registerVerify: jest.fn(),
|
|
234
|
+
commit: jest.fn(),
|
|
235
|
+
}),
|
|
236
|
+
).rejects.toThrow(PASSKEY_UNSUPPORTED_MESSAGE);
|
|
237
|
+
expect(getRegisterOptions).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
import type { LoginSessionResult } from '@oxy.so/contracts';
|
|
2
3
|
import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, AccountDialogController, AccountDialogView, ApiError, SessionClient, SessionMode, OxyAuthScreen } from '@oxy.so/core';
|
|
3
4
|
import type { UseFollowHook } from '../hooks/useFollow.types';
|
|
4
5
|
import type { useLanguageManagement } from '../hooks/useLanguageManagement';
|
|
@@ -104,7 +105,12 @@ export interface OxyContextState {
|
|
|
104
105
|
removePasskey: (credentialId: string) => Promise<void>;
|
|
105
106
|
|
|
106
107
|
revokeSuspiciousSignIn: () => Promise<void>;
|
|
107
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Commit a session a first-party sign-in minted on THIS origin — an OAuth
|
|
110
|
+
* code exchange, or a passkey registration on auth.oxy.so (sign-up,
|
|
111
|
+
* recovery) — as the active one.
|
|
112
|
+
*/
|
|
113
|
+
handleWebSession: (session: SessionLoginResponse | LoginSessionResult) => Promise<void>;
|
|
108
114
|
|
|
109
115
|
/**
|
|
110
116
|
* Run a WEB third-party OAuth sign-in (authorization code + PKCE) end to end
|
|
@@ -115,3 +115,31 @@ export async function runPasskeyAdd(deps: RunPasskeyAddDeps): Promise<void> {
|
|
|
115
115
|
}
|
|
116
116
|
deps.onLinked();
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
/** Injected dependencies for {@link runPasskeyRegisterSignIn}. */
|
|
120
|
+
export interface RunPasskeyRegisterSignInDeps {
|
|
121
|
+
isSupported: () => boolean;
|
|
122
|
+
getRegisterOptions: () => Promise<unknown>;
|
|
123
|
+
runCeremony: (optionsJSON: unknown) => Promise<unknown>;
|
|
124
|
+
registerVerify: (response: unknown) => Promise<PasskeyRegisterVerifyResult>;
|
|
125
|
+
commit: (result: LoginSessionResult) => Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Register a passkey SIGNED OUT and sign in with the account it belongs to —
|
|
130
|
+
* a new passkey account (username + confirmed recovery email), or a recovery's
|
|
131
|
+
* new passkey (ADR 0029 D3). Options, ceremony, verify, then commit; the verify
|
|
132
|
+
* must mint a session, since there is no bearer to link to.
|
|
133
|
+
*/
|
|
134
|
+
export async function runPasskeyRegisterSignIn(deps: RunPasskeyRegisterSignInDeps): Promise<void> {
|
|
135
|
+
if (!deps.isSupported()) {
|
|
136
|
+
throw new Error(PASSKEY_UNSUPPORTED_MESSAGE);
|
|
137
|
+
}
|
|
138
|
+
const options = await deps.getRegisterOptions();
|
|
139
|
+
const response = await deps.runCeremony(options);
|
|
140
|
+
const result = await deps.registerVerify(response);
|
|
141
|
+
if (!('sessionId' in result)) {
|
|
142
|
+
throw new Error('The passkey was registered without signing in.');
|
|
143
|
+
}
|
|
144
|
+
await deps.commit(result);
|
|
145
|
+
}
|
|
@@ -30,7 +30,7 @@ import { SurfaceHeaderAction } from '../components/SurfaceHeaderAction';
|
|
|
30
30
|
import { useOxy } from '../context/OxyContext';
|
|
31
31
|
import { useProfileEditing } from '../hooks/useProfileEditing';
|
|
32
32
|
import { toast } from '@oxy.so/bloom/toast';
|
|
33
|
-
import {
|
|
33
|
+
import { DISPLAY_NAME_INVALID_MESSAGE, isValidDisplayName } from '@oxy.so/core';
|
|
34
34
|
import { getLinkTitle, getLinkDescription, linksToListItems } from './linkFormat';
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -39,7 +39,6 @@ import { getLinkTitle, getLinkDescription, linksToListItems } from './linkFormat
|
|
|
39
39
|
export type ProfileFieldType =
|
|
40
40
|
| 'displayName'
|
|
41
41
|
| 'username'
|
|
42
|
-
| 'email'
|
|
43
42
|
| 'bio'
|
|
44
43
|
| 'phone'
|
|
45
44
|
| 'address'
|
|
@@ -214,7 +213,6 @@ function buildInitialProfileState(
|
|
|
214
213
|
fieldValues.address = String(userData.address || '');
|
|
215
214
|
break;
|
|
216
215
|
case 'username':
|
|
217
|
-
case 'email':
|
|
218
216
|
case 'bio':
|
|
219
217
|
case 'phone':
|
|
220
218
|
fieldValues[fieldType] = String(userData[fieldType] || '');
|
|
@@ -318,30 +316,6 @@ const EditProfileFieldScreen: React.FC<EditProfileFieldScreenProps> = ({
|
|
|
318
316
|
},
|
|
319
317
|
],
|
|
320
318
|
};
|
|
321
|
-
case 'email':
|
|
322
|
-
return {
|
|
323
|
-
title: t('editProfile.items.email.title') || 'Email',
|
|
324
|
-
subtitle: t('editProfile.items.email.subtitle') || 'Your primary email address',
|
|
325
|
-
fields: [
|
|
326
|
-
{
|
|
327
|
-
key: 'email',
|
|
328
|
-
label: t('editProfile.items.email.label') || 'Email Address',
|
|
329
|
-
placeholder: t('editProfile.items.email.placeholder') || 'Enter your email address',
|
|
330
|
-
type: 'email',
|
|
331
|
-
validation: (value) => {
|
|
332
|
-
if (!EMAIL_REGEX.test(value)) {
|
|
333
|
-
return t('editProfile.items.email.invalid') || 'Please enter a valid email address';
|
|
334
|
-
}
|
|
335
|
-
return undefined;
|
|
336
|
-
},
|
|
337
|
-
inputProps: {
|
|
338
|
-
keyboardType: 'email-address',
|
|
339
|
-
autoCapitalize: 'none',
|
|
340
|
-
autoCorrect: false,
|
|
341
|
-
},
|
|
342
|
-
},
|
|
343
|
-
],
|
|
344
|
-
};
|
|
345
319
|
case 'bio':
|
|
346
320
|
return {
|
|
347
321
|
title: t('editProfile.items.bio.title') || 'Bio',
|
|
@@ -89,12 +89,6 @@ const EditProfileScreen: React.FC<BaseScreenProps> = ({ navigate }) => {
|
|
|
89
89
|
description={user?.username ? `@${user.username}` : notSet}
|
|
90
90
|
onPress={() => goToField('username')}
|
|
91
91
|
/>
|
|
92
|
-
<SettingsListItem
|
|
93
|
-
icon={<SettingsIcon name="email" color={bloomTheme.colors.success} />}
|
|
94
|
-
title={t('editProfile.items.email.title') || 'Email'}
|
|
95
|
-
description={user?.email || notSet}
|
|
96
|
-
onPress={() => goToField('email')}
|
|
97
|
-
/>
|
|
98
92
|
<SettingsListItem
|
|
99
93
|
icon={<SettingsIcon name="phone" color={bloomTheme.colors.warning} />}
|
|
100
94
|
title={t('editProfile.items.phone.title') || 'Phone Number'}
|
|
@@ -323,10 +323,10 @@ const ManageAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
323
323
|
);
|
|
324
324
|
return;
|
|
325
325
|
}
|
|
326
|
-
// Web:
|
|
327
|
-
// auth.oxy.so
|
|
326
|
+
// Web: a passkey account confirms its deletion with its passkey, which
|
|
327
|
+
// only auth.oxy.so asserts — the deletion happens there, never on this page.
|
|
328
328
|
if (isWebBrowser()) {
|
|
329
|
-
await Linking.openURL(`${AUTH_WEB_ORIGIN}/
|
|
329
|
+
await Linking.openURL(`${AUTH_WEB_ORIGIN}/delete-account`).catch(() => {
|
|
330
330
|
toast.error(t('accountSwitcher.linkOpenFailed'));
|
|
331
331
|
});
|
|
332
332
|
return;
|
|
@@ -100,12 +100,12 @@ export async function runAccountDeletionHandoff(
|
|
|
100
100
|
return 'handled';
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
const site = `${AUTH_WEB_ORIGIN.replace(/^https?:\/\//, '')}/
|
|
103
|
+
const site = `${AUTH_WEB_ORIGIN.replace(/^https?:\/\//, '')}/delete-account`;
|
|
104
104
|
await surfaces.confirm({
|
|
105
105
|
title: text('deleteAccount.handoff.elsewhereTitle', 'Delete your account where your identity is'),
|
|
106
106
|
description: text(
|
|
107
107
|
'deleteAccount.handoff.elsewhereMessage',
|
|
108
|
-
`Deleting your account needs your identity key, and it isn't on this device. Open Oxy Commons on the device that holds
|
|
108
|
+
`Deleting your account needs your identity key, and it isn't on this device. Open Oxy Commons on the device that holds it and go to Settings > Delete account. An account that uses a passkey is deleted at ${site}.`,
|
|
109
109
|
{ site },
|
|
110
110
|
),
|
|
111
111
|
confirmLabel: text('deleteAccount.handoff.gotIt', 'Got it'),
|