mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
279 lines
9.8 KiB
TypeScript
279 lines
9.8 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import styles from '@app/features/app/components/ErrorFallback.module.css';
|
|
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
|
import type {DomainMigrationSide} from '@app/features/app/domain_migration/DomainMigrationCore';
|
|
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
|
import {
|
|
readPasskeyBridgePageLoginReturnPath,
|
|
readPasskeyBridgeReturn,
|
|
} from '@app/features/auth/passkey_migration/PasskeyBridgeReturn';
|
|
import {
|
|
CANCEL_DESCRIPTOR,
|
|
CONTINUE_DESCRIPTOR,
|
|
TRY_AGAIN_DESCRIPTOR,
|
|
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import {Button} from '@app/features/ui/button/Button';
|
|
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
|
import {PASSKEY_BRIDGE_CHANNEL} from '@fluxer/constants/src/PasskeyConstants';
|
|
import type {PasskeyBridgeFinishResponse} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {useLingui} from '@lingui/react/macro';
|
|
import {type PublicKeyCredentialRequestOptionsJSON, startAuthentication} from '@simplewebauthn/browser';
|
|
import type React from 'react';
|
|
import {useCallback, useEffect, useState} from 'react';
|
|
|
|
const CEREMONY_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
|
|
|
|
const USE_YOUR_PASSKEY_DESCRIPTOR = msg({
|
|
message: 'Use your passkey',
|
|
comment: 'Heading of the page that asks the user to confirm with their passkey.',
|
|
});
|
|
const PRESS_CONTINUE_DESCRIPTOR = msg({
|
|
message: 'Press Continue to use your passkey.',
|
|
comment: 'Body of the page that asks the user to confirm with their passkey. Continue is the button label.',
|
|
});
|
|
const THAT_DIDN_T_WORK_DESCRIPTOR = msg({
|
|
message: "That didn't work. Try again or cancel.",
|
|
comment: 'Body of the passkey page after the passkey prompt failed or was dismissed. Keep plain.',
|
|
});
|
|
const REQUEST_EXPIRED_DESCRIPTOR = msg({
|
|
message: 'This request has expired. Go back and try again.',
|
|
comment: 'Body of the passkey page when the passkey request is no longer valid. Keep plain.',
|
|
});
|
|
const BACK_TO_PRODUCT_DESCRIPTOR = msg({
|
|
message: 'Back to {productName}',
|
|
comment: 'Button on the expired passkey page that returns to the app. productName is the app name.',
|
|
});
|
|
const YOU_CAN_CLOSE_THIS_TAB_DESCRIPTOR = msg({
|
|
message: 'You can close this tab',
|
|
comment: 'Heading of the page shown after a passkey confirmation finished in a separate tab.',
|
|
});
|
|
const GO_BACK_TO_CONTINUE_DESCRIPTOR = msg({
|
|
message: 'Go back to {productName} to continue.',
|
|
comment:
|
|
'Body of the page shown after a passkey confirmation finished in a separate tab. productName is the app name.',
|
|
});
|
|
|
|
type LegacyBridgeState =
|
|
| {status: 'loading'}
|
|
| {status: 'ready'; options: PublicKeyCredentialRequestOptionsJSON}
|
|
| {status: 'running'}
|
|
| {status: 'failed'}
|
|
| {status: 'expired'};
|
|
|
|
class PasskeyBridgeExpiredError extends Error {
|
|
constructor() {
|
|
super('Passkey bridge ceremony is unknown or expired');
|
|
this.name = 'PasskeyBridgeExpiredError';
|
|
}
|
|
}
|
|
|
|
async function postBridge<T>(path: string, body?: unknown): Promise<T> {
|
|
const response = await fetch(`${window.location.origin}/api/v1${path}`, {
|
|
method: 'POST',
|
|
credentials: 'omit',
|
|
...(body === undefined ? {} : {headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}),
|
|
});
|
|
if (response.status === 404) {
|
|
throw new PasskeyBridgeExpiredError();
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`Passkey bridge request failed with status ${response.status}`);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
function leave(finish: PasskeyBridgeFinishResponse): void {
|
|
if (finish.return_url === null) {
|
|
throw new Error('Passkey bridge finished without a return address');
|
|
}
|
|
window.location.replace(finish.return_url);
|
|
}
|
|
|
|
const isDismissed = (error: unknown): boolean =>
|
|
error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'AbortError');
|
|
|
|
interface PasskeyBridgePageProps {
|
|
side: DomainMigrationSide;
|
|
hash: string;
|
|
opensInOwnTab: boolean;
|
|
}
|
|
|
|
const LegacyPasskeyBridgePage: React.FC<PasskeyBridgePageProps> = ({side, hash, opensInOwnTab}) => {
|
|
const {i18n} = useLingui();
|
|
const ceremonyId = CEREMONY_ID_PATTERN.test(hash.slice(1)) ? hash.slice(1) : null;
|
|
const [state, setState] = useState<LegacyBridgeState>(() =>
|
|
ceremonyId === null ? {status: 'expired'} : {status: 'loading'},
|
|
);
|
|
const fail = useCallback((error: unknown) => {
|
|
setState(error instanceof PasskeyBridgeExpiredError ? {status: 'expired'} : {status: 'failed'});
|
|
}, []);
|
|
const loadOptions = useCallback(() => {
|
|
if (ceremonyId === null) {
|
|
return;
|
|
}
|
|
setState({status: 'loading'});
|
|
postBridge<{options: PublicKeyCredentialRequestOptionsJSON}>(Endpoints.AUTH_PASSKEY_BRIDGE_OPTIONS(ceremonyId))
|
|
.then((response) => {
|
|
setState({status: 'ready', options: response.options});
|
|
})
|
|
.catch(fail);
|
|
}, [ceremonyId, fail]);
|
|
useEffect(loadOptions, [loadOptions]);
|
|
const cancel = useCallback(() => {
|
|
if (ceremonyId === null) {
|
|
return;
|
|
}
|
|
setState({status: 'running'});
|
|
postBridge<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_CANCEL(ceremonyId))
|
|
.then(leave)
|
|
.catch(() => {
|
|
setState({status: 'expired'});
|
|
});
|
|
}, [ceremonyId]);
|
|
const handleContinue = useCallback(() => {
|
|
if (ceremonyId === null || state.status !== 'ready') {
|
|
return;
|
|
}
|
|
setState({status: 'running'});
|
|
startAuthentication({optionsJSON: state.options}).then(
|
|
(response) =>
|
|
postBridge<PasskeyBridgeFinishResponse>(Endpoints.AUTH_PASSKEY_BRIDGE_COMPLETE(ceremonyId), {response})
|
|
.then(leave)
|
|
.catch((error: unknown) => {
|
|
if (opensInOwnTab || error instanceof PasskeyBridgeExpiredError) {
|
|
fail(error);
|
|
return;
|
|
}
|
|
cancel();
|
|
}),
|
|
(error: unknown) => {
|
|
if (!opensInOwnTab && isDismissed(error)) {
|
|
cancel();
|
|
return;
|
|
}
|
|
setState({status: 'failed'});
|
|
},
|
|
);
|
|
}, [cancel, ceremonyId, fail, opensInOwnTab, state]);
|
|
const handleBack = useCallback(() => {
|
|
window.close();
|
|
window.location.replace(`${side.target}/`);
|
|
}, [side.target]);
|
|
const expired = state.status === 'expired';
|
|
const failed = state.status === 'failed';
|
|
return (
|
|
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-page.container">
|
|
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-page.icon" />
|
|
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-page.content">
|
|
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-page.title">
|
|
{i18n._(USE_YOUR_PASSKEY_DESCRIPTOR)}
|
|
</h1>
|
|
<p
|
|
className={styles.errorFallbackDescription}
|
|
role={expired || failed ? 'alert' : undefined}
|
|
data-flx="auth.passkey-bridge-page.description"
|
|
>
|
|
{i18n._(
|
|
expired ? REQUEST_EXPIRED_DESCRIPTOR : failed ? THAT_DIDN_T_WORK_DESCRIPTOR : PRESS_CONTINUE_DESCRIPTOR,
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className={styles.errorFallbackActions} data-flx="auth.passkey-bridge-page.actions">
|
|
{expired ? (
|
|
<Button onClick={handleBack} autoFocus data-flx="auth.passkey-bridge-page.button.back">
|
|
{i18n._(BACK_TO_PRODUCT_DESCRIPTOR, {productName: RuntimeConfig.productName})}
|
|
</Button>
|
|
) : failed ? (
|
|
<>
|
|
<Button
|
|
variant={opensInOwnTab ? 'secondary' : 'primary'}
|
|
onClick={loadOptions}
|
|
autoFocus={!opensInOwnTab}
|
|
data-flx="auth.passkey-bridge-page.button.try-again"
|
|
>
|
|
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
|
</Button>
|
|
<Button
|
|
variant={opensInOwnTab ? 'primary' : 'ghost'}
|
|
onClick={cancel}
|
|
autoFocus={opensInOwnTab}
|
|
data-flx="auth.passkey-bridge-page.button.cancel"
|
|
>
|
|
{i18n._(CANCEL_DESCRIPTOR)}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
onClick={handleContinue}
|
|
submitting={state.status !== 'ready'}
|
|
autoFocus
|
|
data-flx="auth.passkey-bridge-page.button.continue"
|
|
>
|
|
{i18n._(CONTINUE_DESCRIPTOR)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={cancel}
|
|
disabled={state.status === 'running'}
|
|
data-flx="auth.passkey-bridge-page.button.cancel"
|
|
>
|
|
{i18n._(CANCEL_DESCRIPTOR)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
const TargetPasskeyBridgePage: React.FC<{hash: string}> = ({hash}) => {
|
|
const {i18n} = useLingui();
|
|
const [bridgeReturn] = useState(() => readPasskeyBridgeReturn(hash));
|
|
const [loginReturnPath] = useState(() =>
|
|
bridgeReturn === null ? null : readPasskeyBridgePageLoginReturnPath(bridgeReturn.ceremonyId),
|
|
);
|
|
useEffect(() => {
|
|
if (loginReturnPath !== null) {
|
|
window.location.replace(`${window.location.origin}${loginReturnPath}${hash}`);
|
|
return;
|
|
}
|
|
if (bridgeReturn !== null) {
|
|
const channel = new BroadcastChannel(PASSKEY_BRIDGE_CHANNEL);
|
|
channel.postMessage({ceremony_id: bridgeReturn.ceremonyId, completion_code: bridgeReturn.completionCode});
|
|
channel.close();
|
|
}
|
|
window.close();
|
|
}, [bridgeReturn, hash, loginReturnPath]);
|
|
if (loginReturnPath !== null) {
|
|
return null;
|
|
}
|
|
return (
|
|
<main className={styles.errorFallbackContainer} data-flx="auth.passkey-bridge-page.container">
|
|
<FluxerIcon className={styles.errorFallbackIcon} data-flx="auth.passkey-bridge-page.icon" />
|
|
<div className={styles.errorFallbackContent} data-flx="auth.passkey-bridge-page.content">
|
|
<h1 className={styles.errorFallbackTitle} data-flx="auth.passkey-bridge-page.title">
|
|
{i18n._(YOU_CAN_CLOSE_THIS_TAB_DESCRIPTOR)}
|
|
</h1>
|
|
<p className={styles.errorFallbackDescription} data-flx="auth.passkey-bridge-page.description">
|
|
{i18n._(GO_BACK_TO_CONTINUE_DESCRIPTOR, {productName: RuntimeConfig.productName})}
|
|
</p>
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export const PasskeyBridgePage: React.FC<PasskeyBridgePageProps> = ({side, hash, opensInOwnTab}) =>
|
|
side.role === 'source' ? (
|
|
<LegacyPasskeyBridgePage
|
|
side={side}
|
|
hash={hash}
|
|
opensInOwnTab={opensInOwnTab}
|
|
data-flx="auth.passkey-bridge-page.legacy"
|
|
/>
|
|
) : (
|
|
<TargetPasskeyBridgePage hash={hash} data-flx="auth.passkey-bridge-page.target" />
|
|
);
|