mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
|
|
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
|
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
|
|
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
|
import {Button} from '@app/features/ui/button/Button';
|
|
import {Input} from '@app/features/ui/components/form/FormInput';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {Trans, useLingui} from '@lingui/react/macro';
|
|
import {CheckCircleIcon, ShieldWarningIcon} from '@phosphor-icons/react';
|
|
import type React from 'react';
|
|
import {useCallback, useState} from 'react';
|
|
|
|
const SIGN_IN_CODE_DESCRIPTOR = msg({
|
|
message: 'Sign-in code',
|
|
comment: 'Short label in the authentication handoff approval flow. Keep the tone plain and specific.',
|
|
});
|
|
const CODE_LENGTH = 12;
|
|
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{12}$/;
|
|
|
|
function formatCodeForDisplay(raw: string): string {
|
|
const cleaned = raw
|
|
.replace(/[^A-Za-z0-9]/g, '')
|
|
.toUpperCase()
|
|
.slice(0, CODE_LENGTH);
|
|
if (cleaned.length <= 6) {
|
|
return cleaned;
|
|
}
|
|
return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`;
|
|
}
|
|
|
|
function extractRawCode(formatted: string): string {
|
|
return formatted
|
|
.replace(/[^A-Za-z0-9]/g, '')
|
|
.toUpperCase()
|
|
.slice(0, CODE_LENGTH);
|
|
}
|
|
|
|
function formatLocation(location: {
|
|
city?: string | null;
|
|
region?: string | null;
|
|
country?: string | null;
|
|
}): string | null {
|
|
const parts = [location.city, location.region, location.country].filter(Boolean);
|
|
return parts.length > 0 ? parts.join(', ') : null;
|
|
}
|
|
|
|
interface HandoffApprovalFlowProps {
|
|
mode: DesktopHandoffMode;
|
|
error: string | null;
|
|
clientInfo: DesktopHandoffInfoResponse['client_info'];
|
|
onProceedToCodeInput: () => void;
|
|
onSubmitCode: (code: string) => void;
|
|
onApprove: () => void;
|
|
onDeny: () => void;
|
|
onRetry: () => void;
|
|
}
|
|
|
|
export function HandoffApprovalFlow({
|
|
mode,
|
|
error,
|
|
clientInfo,
|
|
onProceedToCodeInput,
|
|
onSubmitCode,
|
|
onApprove,
|
|
onDeny,
|
|
onRetry,
|
|
}: HandoffApprovalFlowProps) {
|
|
const {i18n} = useLingui();
|
|
const [codeInput, setCodeInput] = useState('');
|
|
const handleCodeChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const rawCode = extractRawCode(e.target.value);
|
|
setCodeInput(rawCode);
|
|
if (VALID_CODE_PATTERN.test(rawCode)) {
|
|
onSubmitCode(rawCode);
|
|
}
|
|
},
|
|
[onSubmitCode],
|
|
);
|
|
if (mode === 'warning') {
|
|
return (
|
|
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container">
|
|
<div className={styles.warningBox} data-flx="auth.flow.handoff-approval-flow.warning-box">
|
|
<ShieldWarningIcon
|
|
size={remFromPx(32)}
|
|
weight="fill"
|
|
className={styles.warningIcon}
|
|
data-flx="auth.flow.handoff-approval-flow.warning-icon"
|
|
/>
|
|
<h1 className={styles.warningTitle} data-flx="auth.flow.handoff-approval-flow.warning-title">
|
|
<Trans>Sign-in request</Trans>
|
|
</h1>
|
|
<p className={styles.warningDescription} data-flx="auth.flow.handoff-approval-flow.warning-description">
|
|
<Trans>
|
|
You are about to authorize an app to sign in to your account. Only continue if you initiated this request
|
|
from a device you control.
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={onProceedToCodeInput}
|
|
variant="primary"
|
|
fitContainer
|
|
data-flx="auth.flow.handoff-approval-flow.button.proceed-to-code-input"
|
|
>
|
|
<Trans>Continue</Trans>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
if (mode === 'code_input') {
|
|
return (
|
|
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--2">
|
|
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title">
|
|
<Trans>Enter the code</Trans>
|
|
</h1>
|
|
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description">
|
|
<Trans>Enter the code shown in the app to continue.</Trans>
|
|
</p>
|
|
<div className={styles.codeInputSection} data-flx="auth.flow.handoff-approval-flow.code-input-section">
|
|
<Input
|
|
aria-label={i18n._(SIGN_IN_CODE_DESCRIPTOR)}
|
|
name="desktop_handoff_code"
|
|
value={formatCodeForDisplay(codeInput)}
|
|
onChange={handleCodeChange}
|
|
autoComplete="off"
|
|
autoCapitalize="characters"
|
|
autoFocus
|
|
enterKeyHint="done"
|
|
spellCheck={false}
|
|
data-flx="auth.flow.handoff-approval-flow.input.code-change"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (mode === 'fetching_info') {
|
|
return (
|
|
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--3">
|
|
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--2">
|
|
<Trans>Verifying code…</Trans>
|
|
</h1>
|
|
<div className={styles.spinner} data-flx="auth.flow.handoff-approval-flow.spinner">
|
|
<span className={styles.spinnerIcon} data-flx="auth.flow.handoff-approval-flow.spinner-icon" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (mode === 'approving') {
|
|
const platform = clientInfo?.platform ?? null;
|
|
const os = clientInfo?.os ?? null;
|
|
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
|
|
const hasAnyDeviceInfo = Boolean(platform || os || location);
|
|
return (
|
|
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--4">
|
|
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--3">
|
|
<Trans>Was this you?</Trans>
|
|
</h1>
|
|
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--2">
|
|
{hasAnyDeviceInfo ? (
|
|
<Trans>A sign-in request was made from the following device. Only approve if you recognize it.</Trans>
|
|
) : (
|
|
<Trans>A sign-in request was made for your account. Only approve if you initiated it.</Trans>
|
|
)}
|
|
</p>
|
|
{hasAnyDeviceInfo ? (
|
|
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
|
|
{platform ? (
|
|
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
|
|
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
|
|
<Trans>Platform</Trans>
|
|
</span>
|
|
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
|
|
{platform}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{os ? (
|
|
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--2">
|
|
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--2">
|
|
<Trans>Operating system</Trans>
|
|
</span>
|
|
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--2">
|
|
{os}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{location ? (
|
|
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--3">
|
|
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--3">
|
|
<Trans>Location</Trans>
|
|
</span>
|
|
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--3">
|
|
{location}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<div className={styles.buttonRow} data-flx="auth.flow.handoff-approval-flow.button-row">
|
|
<Button onClick={onDeny} variant="secondary" data-flx="auth.flow.handoff-approval-flow.button.deny">
|
|
<Trans>Deny</Trans>
|
|
</Button>
|
|
<Button onClick={onApprove} variant="primary" data-flx="auth.flow.handoff-approval-flow.button.approve">
|
|
<Trans>Approve</Trans>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (mode === 'completing') {
|
|
return (
|
|
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--5">
|
|
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--4">
|
|
<Trans>Completing sign-in…</Trans>
|
|
</h1>
|
|
<div className={styles.spinner} data-flx="auth.flow.handoff-approval-flow.spinner--2">
|
|
<span className={styles.spinnerIcon} data-flx="auth.flow.handoff-approval-flow.spinner-icon--2" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (mode === 'done') {
|
|
return (
|
|
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--6">
|
|
<CheckCircleIcon
|
|
size={remFromPx(48)}
|
|
weight="fill"
|
|
className={styles.successIcon}
|
|
data-flx="auth.flow.handoff-approval-flow.success-icon"
|
|
/>
|
|
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--5">
|
|
<Trans>Sign-in approved</Trans>
|
|
</h1>
|
|
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--3">
|
|
<Trans>The app should now be signed in to your account.</Trans>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
if (mode === 'error') {
|
|
return (
|
|
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--7">
|
|
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--6">
|
|
<Trans>Something went wrong</Trans>
|
|
</h1>
|
|
{error ? (
|
|
<p className={styles.error} role="alert" data-flx="auth.flow.handoff-approval-flow.error">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
<Button onClick={onRetry} fitContainer data-flx="auth.flow.handoff-approval-flow.button.retry">
|
|
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface ConnectedHandoffApprovalFlowProps {
|
|
handoff: {
|
|
mode: DesktopHandoffMode;
|
|
error: string | null;
|
|
clientInfo: DesktopHandoffInfoResponse['client_info'];
|
|
proceedToCodeInput: () => void;
|
|
submitCode: (code: string) => void;
|
|
approve: () => void;
|
|
deny: () => void;
|
|
retry: () => void;
|
|
};
|
|
}
|
|
|
|
export function ConnectedHandoffApprovalFlow({handoff}: ConnectedHandoffApprovalFlowProps) {
|
|
return (
|
|
<HandoffApprovalFlow
|
|
mode={handoff.mode}
|
|
error={handoff.error}
|
|
clientInfo={handoff.clientInfo}
|
|
onProceedToCodeInput={handoff.proceedToCodeInput}
|
|
onSubmitCode={handoff.submitCode}
|
|
onApprove={handoff.approve}
|
|
onDeny={handoff.deny}
|
|
onRetry={handoff.retry}
|
|
data-flx="auth.flow.handoff-approval-flow.connected-handoff-approval-flow.handoff-approval-flow"
|
|
/>
|
|
);
|
|
}
|