feat(desktop): sign in with the browser without typing a code (#3286)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 17:31:45 +02:00
1 parent 510cc1b916
commit c5453f83e8
57 files changed
+7060 -3854

No files matched your search

@@ -131,14 +131,17 @@ interface RecoveryKitIssued {
export type RecoverAccountResponse = (AuthTokenResponse | MfaLoginResponse) & RecoveryKitIssued;
export type DesktopHandoffReturnMethod = 'deep_link' | 'code';
interface DesktopHandoffInitiateResponse {
code: string;
expires_at: string;
poll_secret?: string;
return_method?: DesktopHandoffReturnMethod;
}
interface DesktopHandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string;
user_id?: string;
user?: AuthResponseUser | null;
@@ -147,6 +150,7 @@ interface DesktopHandoffStatusResponse {
interface DesktopHandoffInfoClientInfo {
platform?: string | null;
os?: string | null;
device?: 'mobile' | 'desktop';
location?: {
city?: string | null;
region?: string | null;
@@ -157,6 +161,11 @@ interface DesktopHandoffInfoClientInfo {
export interface DesktopHandoffInfoResponse {
status: 'pending' | 'expired';
client_info?: DesktopHandoffInfoClientInfo | null;
return_method?: DesktopHandoffReturnMethod;
}
interface DesktopHandoffCompleteResponse {
return_url?: string;
}
export type LoginIdentifier = {email: string; login?: undefined} | {login: string; email?: undefined};
@@ -730,21 +739,34 @@ export async function completeDesktopHandoff({
code,
token,
userId,
returnMethod,
target,
}: {
code: string;
token: string;
userId: string;
returnMethod: DesktopHandoffReturnMethod;
target: InstanceHTTPTarget;
}): Promise<void> {
await instanceRequest({
}): Promise<string | null> {
const response = await instanceRequest<DesktopHandoffCompleteResponse | null>({
method: 'POST',
path: Endpoints.AUTH_HANDOFF_COMPLETE,
target,
body: {code, user_id: userId},
body: {code, user_id: userId, return_method: returnMethod},
headers: withAuthLocaleHeader({Authorization: token}),
auth: 'none',
});
return response.body?.return_url ?? null;
}
export async function denyDesktopHandoff({code, target}: {code: string; target: InstanceHTTPTarget}): Promise<void> {
await instanceRequest({
method: 'POST',
path: Endpoints.AUTH_HANDOFF_DENY(code),
target,
headers: withAuthLocaleHeader(),
auth: 'none',
});
}
export async function fetchDesktopHandoffInfo({
@@ -44,7 +44,7 @@ import {AuthClientPreferencesStep} from '@app/features/auth/flow/client_intro/Au
import {AuthClientWelcomeStep} from '@app/features/auth/flow/client_intro/AuthClientWelcomeStep';
import {useDesktopClientIntroFlow} from '@app/features/auth/flow/client_intro/useDesktopClientIntroFlow';
import DesktopHandoffAccountSelector from '@app/features/auth/flow/DesktopHandoffAccountSelector';
import {ConnectedHandoffApprovalFlow} from '@app/features/auth/flow/HandoffApprovalFlow';
import {HandoffApprovalFlow} from '@app/features/auth/flow/HandoffApprovalFlow';
import IpAuthorizationScreen from '@app/features/auth/flow/IpAuthorizationScreen';
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
import {getAuthErrorMessage} from '@app/features/auth/hooks/useAuthForm';
@@ -992,12 +992,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
);
}
if (authLoginStep === AuthLoginStep.DESKTOP_HANDOFF_APPROVAL) {
return (
<ConnectedHandoffApprovalFlow
handoff={handoff}
data-flx="auth.flow.auth-login-layout.connected-handoff-approval-flow"
/>
);
return <HandoffApprovalFlow handoff={handoff} data-flx="auth.flow.auth-login-layout.handoff-approval-flow" />;
}
if (authLoginStep === AuthLoginStep.IP_AUTHORIZATION && ipAuthChallenge != null) {
const runtimeSnapshot = requireAuthInstanceSnapshot(resolvedAuthRuntimeSnapshot);
@@ -50,7 +50,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
if (!token) {
throw new Error('Failed to generate token');
}
onAccountSelected({token, userId, runtimeSnapshot});
onAccountSelected({token, userId, runtimeSnapshot, userData: account.userData});
} catch (err) {
if (err instanceof SessionExpiredError) {
onReLoginAccount(Accounts.getAccount(account.storageKey) ?? account);
@@ -74,13 +74,7 @@ export const DesktopHandoffMfaStep = observer(function DesktopHandoffMfaStep({
if (isHandoff && isApprovalFlowMode(handoff.mode)) {
return (
<HandoffApprovalFlow
mode={handoff.mode}
error={handoff.error}
clientInfo={handoff.clientInfo}
onProceedToCodeInput={handoff.proceedToCodeInput}
onSubmitCode={handoff.submitCode}
onApprove={handoff.approve}
onDeny={handoff.deny}
handoff={handoff}
onRetry={handleHandoffRetry}
data-flx="auth.flow.desktop-handoff-mfa-step.handoff-approval-flow"
/>
@@ -25,39 +25,83 @@
line-height: 1.5;
}
.warningBox {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1.125rem;
background-color: hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.06);
border: 0.125rem solid hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.25);
border-radius: 0.625rem;
margin-bottom: 1rem;
}
.warningIcon {
color: var(--status-warning);
}
.warningTitle {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.warningDescription {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.codeInputSection {
margin-bottom: 0.75rem;
width: 100%;
text-align: left;
}
.codeInput {
font-family: var(--font-mono);
font-size: 1.125rem;
letter-spacing: 0.08em;
text-align: center;
text-transform: uppercase;
}
.code {
display: inline-block;
padding: 0.5rem 0.75rem;
border: 0.0625rem solid var(--background-modifier-accent);
border-radius: 0.5rem;
background: var(--background-tertiary);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.1;
overflow-wrap: anywhere;
}
.matchCheck {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
margin: 0.25rem 0 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.4;
}
.accountLine {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.25rem 0.5rem;
width: 100%;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.wrongAccount {
width: 100%;
margin: 0;
padding: 0.625rem 0.75rem;
border: 0.0625rem solid hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.25);
border-radius: 0.5rem;
background-color: hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.06);
color: var(--text-primary);
line-height: 1.4;
}
.textButton {
padding: 0.125rem 0.25rem;
border: none;
border-radius: 0.25rem;
background: none;
color: var(--text-link);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.textButton:hover {
text-decoration: underline;
}
.deviceCard {
@@ -129,3 +173,7 @@
.successIcon {
color: var(--status-online);
}
.deniedIcon {
color: var(--text-tertiary);
}
@@ -1,7 +1,8 @@
// 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/AuthLoginStepTypes';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/AuthLoginStepTypes';
import type {DesktopHandoffFlow} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
import {
CANCEL_DESCRIPTOR,
@@ -12,6 +13,7 @@ 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 {Spinner} from '@app/features/ui/components/Spinner';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {
formatDesktopHandoffCode,
isDesktopHandoffCode,
@@ -19,13 +21,18 @@ import {
} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {CheckCircleIcon, ShieldWarningIcon} from '@phosphor-icons/react';
import {CheckCircleIcon, ProhibitIcon} 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 APP_CODE_LABEL_DESCRIPTOR = msg({
message: 'Code from the {productName} app',
comment:
'Label of the field in the browser where the user types the 12-character code shown in the desktop or mobile app. productName is the app name, such as Fluxer.',
});
const SIGN_IN_DESCRIPTOR = msg({
message: 'Sign in',
comment: 'Button in the browser that approves signing in to the desktop or mobile app that asked for it.',
});
function formatLocation(location: {
@@ -37,85 +44,159 @@ function formatLocation(location: {
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;
function HandoffCode({code}: {code: string}) {
return (
<span className={styles.code} data-flx="auth.flow.handoff-approval-flow.code">
{formatDesktopHandoffCode(code)}
</span>
);
}
export function HandoffApprovalFlow({
mode,
error,
clientInfo,
onProceedToCodeInput,
onSubmitCode,
onApprove,
onDeny,
onRetry,
}: HandoffApprovalFlowProps) {
function ApprovalTitle({handoff}: {handoff: DesktopHandoffFlow}) {
if (handoff.returnMethod === 'deep_link' || handoff.clientInfo?.device === 'desktop') {
return (
<Trans comment="Title of the browser page where the user approves signing in to the desktop app that opened it. PRODUCT_NAME is the app name, such as Fluxer.">
Sign in to the {PRODUCT_NAME} desktop app
</Trans>
);
}
if (handoff.clientInfo?.device === 'mobile') {
return (
<Trans comment="Title of the browser page where the user approves signing in to the mobile app that asked for it. PRODUCT_NAME is the app name, such as Fluxer.">
Sign in to the {PRODUCT_NAME} mobile app
</Trans>
);
}
return (
<Trans comment="Title of the browser page where the user approves signing in to an app that asked for it. PRODUCT_NAME is the app name, such as Fluxer.">
Sign in to the {PRODUCT_NAME} app
</Trans>
);
}
function DeviceCard({clientInfo}: {clientInfo: DesktopHandoffFlow['clientInfo']}) {
const platform = clientInfo?.platform ?? null;
const os = clientInfo?.os ?? null;
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
if (!platform && !os && !location) {
return null;
}
return (
<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>
);
}
function AccountLine({handoff, onUseAnotherAccount}: {handoff: DesktopHandoffFlow; onUseAnotherAccount: () => void}) {
const account = handoff.account;
if (account == null) {
return null;
}
const name = account.globalName ?? account.username;
const requestedIdentifier = handoff.requestedIdentifier;
return (
<div className={styles.accountLine} data-flx="auth.flow.handoff-approval-flow.account-line">
{requestedIdentifier != null ? (
<p className={styles.wrongAccount} role="alert" data-flx="auth.flow.handoff-approval-flow.wrong-account">
<Trans comment="Warning on the browser approval page when the app asked for a different account than the one chosen. requestedIdentifier is the email or username the app asked for, name is the chosen account's display name.">
The {PRODUCT_NAME} app asked to sign in as {requestedIdentifier}, but you chose {name}.
</Trans>
</p>
) : (
<span data-flx="auth.flow.handoff-approval-flow.account-name">
<Trans comment="Shown on the browser approval page above the buttons. name is the display name of the account that will be signed in to the app.">
Signing in as {name}
</Trans>
</span>
)}
<FocusRing offset={-2} data-flx="auth.flow.handoff-approval-flow.focus-ring.use-another-account">
<button
type="button"
className={styles.textButton}
onClick={onUseAnotherAccount}
data-flx="auth.flow.handoff-approval-flow.button.use-another-account"
>
<Trans comment="Text button on the browser approval page that goes back to choosing which account signs in to the app.">
Use a different account
</Trans>
</button>
</FocusRing>
</div>
);
}
interface HandoffApprovalFlowProps {
handoff: DesktopHandoffFlow;
onRetry?: () => void;
}
export function HandoffApprovalFlow({handoff, onRetry}: HandoffApprovalFlowProps) {
const {i18n} = useLingui();
const [codeInput, setCodeInput] = useState('');
const retry = onRetry ?? handoff.retry;
const {submitCode} = handoff;
const handleCodeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const rawCode = parseDesktopHandoffCodeInput(e.target.value);
setCodeInput(rawCode);
if (isDesktopHandoffCode(rawCode)) {
onSubmitCode(rawCode);
void submitCode(rawCode);
}
},
[onSubmitCode],
[submitCode],
);
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') {
if (handoff.mode === DesktopHandoffMode.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>
<Trans comment="Title of the browser page where the user types the 12-character code the app shows under Signing in on another device. PRODUCT_NAME is the app name, such as Fluxer.">
Enter the code from the {PRODUCT_NAME} app
</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>
<Trans comment="Explains where the code comes from, under the title of the browser page where it is typed. Signing in on another device is the label of a button in the app and must match its translation. PRODUCT_NAME is the app name, such as Fluxer.">
The {PRODUCT_NAME} app on your other device shows it under Signing in on another device. Only enter a code
from a device you control.
</Trans>
</p>
<div className={styles.codeInputSection} data-flx="auth.flow.handoff-approval-flow.code-input-section">
<Input
aria-label={i18n._(SIGN_IN_CODE_DESCRIPTOR)}
label={i18n._(APP_CODE_LABEL_DESCRIPTOR, {productName: PRODUCT_NAME})}
name="desktop_handoff_code"
className={styles.codeInput}
value={formatDesktopHandoffCode(codeInput)}
onChange={handleCodeChange}
placeholder="XXXXXX-XXXXXX"
autoComplete="off"
autoCapitalize="characters"
autoFocus
@@ -125,7 +206,7 @@ export function HandoffApprovalFlow({
/>
</div>
<Button
onClick={onRetry}
onClick={retry}
variant="secondary"
fitContainer
data-flx="auth.flow.handoff-approval-flow.button.cancel"
@@ -135,11 +216,17 @@ export function HandoffApprovalFlow({
</div>
);
}
if (mode === 'fetching_info') {
if (handoff.mode === DesktopHandoffMode.FETCHING_INFO || handoff.mode === DesktopHandoffMode.COMPLETING) {
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>
{handoff.mode === DesktopHandoffMode.FETCHING_INFO ? (
<Trans comment="Title while the browser looks up the sign-in request from the app.">
Checking the sign-in request…
</Trans>
) : (
<Trans comment="Title while the browser approves the sign-in to the app.">Signing in…</Trans>
)}
</h1>
<div className={styles.spinner} data-flx="auth.flow.handoff-approval-flow.spinner">
<Spinner data-flx="auth.flow.handoff-approval-flow.spinner-icon" />
@@ -147,81 +234,42 @@ export function HandoffApprovalFlow({
</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);
if (handoff.mode === DesktopHandoffMode.APPROVING) {
const matchCode = handoff.codeSource === 'link' && handoff.returnMethod === 'code' ? handoff.handoffCode : null;
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>
<ApprovalTitle handoff={handoff} />
</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>
)}
<Trans comment="Warning under the title of the browser approval page, so people do not approve a sign-in someone else started.">
Only continue if you started this sign-in on a device you control.
</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}
<AccountLine handoff={handoff} onUseAnotherAccount={retry} />
{matchCode != null ? (
<div className={styles.matchCheck} data-flx="auth.flow.handoff-approval-flow.match-check">
<span data-flx="auth.flow.handoff-approval-flow.match-check-label">
<Trans comment="Shown above the code on the browser approval page when the app cannot be signed in automatically. PRODUCT_NAME is the app name, such as Fluxer.">
Make sure this code matches the one in the {PRODUCT_NAME} app.
</Trans>
</span>
<HandoffCode code={matchCode} />
</div>
) : null}
<DeviceCard clientInfo={handoff.clientInfo} />
<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 onClick={handoff.deny} variant="secondary" data-flx="auth.flow.handoff-approval-flow.button.deny">
{i18n._(CANCEL_DESCRIPTOR)}
</Button>
<Button onClick={onApprove} variant="primary" data-flx="auth.flow.handoff-approval-flow.button.approve">
<Trans>Approve</Trans>
<Button onClick={handoff.approve} variant="primary" data-flx="auth.flow.handoff-approval-flow.button.approve">
{i18n._(SIGN_IN_DESCRIPTOR)}
</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">
<Spinner data-flx="auth.flow.handoff-approval-flow.spinner-icon--2" />
</div>
</div>
);
}
if (mode === 'done') {
if (handoff.mode === DesktopHandoffMode.DONE) {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--6">
<CheckCircleIcon
@@ -231,26 +279,80 @@ export function HandoffApprovalFlow({
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>
<Trans comment="Title of the browser page after the user approved signing in to the app.">
You're signed in
</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>
{handoff.returnUrl != null ? (
<Trans comment="Shown after approving, when the browser has already handed the sign-in back to the desktop app. PRODUCT_NAME is the app name, such as Fluxer.">
Go back to the {PRODUCT_NAME} app. You can close this tab.
</Trans>
) : (
<Trans comment="Shown after approving a sign-in for an app on another device, which signs in by itself shortly. PRODUCT_NAME is the app name, such as Fluxer.">
Go back to the {PRODUCT_NAME} app. It finishes signing in on its own.
</Trans>
)}
</p>
{handoff.returnUrl != null ? (
<Button
onClick={handoff.reopenApp}
variant="secondary"
fitContainer
data-flx="auth.flow.handoff-approval-flow.button.open-app"
>
<Trans comment="Button on the browser page after approving that opens the desktop app again if it did not come to the front. PRODUCT_NAME is the app name, such as Fluxer.">
Open {PRODUCT_NAME}
</Trans>
</Button>
) : null}
</div>
);
}
if (mode === 'error') {
if (handoff.mode === DesktopHandoffMode.DENIED) {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--8">
<ProhibitIcon
size={remFromPx(48)}
weight="regular"
className={styles.deniedIcon}
data-flx="auth.flow.handoff-approval-flow.denied-icon"
/>
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--7">
<Trans comment="Title of the browser page after the user cancelled the sign-in request from the app.">
Sign-in cancelled
</Trans>
</h1>
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--4">
<Trans comment="Shown after the user cancelled the sign-in request in the browser. PRODUCT_NAME is the app name, such as Fluxer.">
The {PRODUCT_NAME} app was not signed in. You can close this tab.
</Trans>
</p>
<Button
onClick={retry}
variant="secondary"
fitContainer
data-flx="auth.flow.handoff-approval-flow.button.restart"
>
<Trans comment="Button on the browser page after cancelling that goes back to choosing an account.">
Start over
</Trans>
</Button>
</div>
);
}
if (handoff.mode === DesktopHandoffMode.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">
{i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR)}
</h1>
{error ? (
{handoff.error ? (
<p className={styles.error} role="alert" data-flx="auth.flow.handoff-approval-flow.error">
{error}
{handoff.error}
</p>
) : null}
<Button onClick={onRetry} fitContainer data-flx="auth.flow.handoff-approval-flow.button.retry">
<Button onClick={retry} fitContainer data-flx="auth.flow.handoff-approval-flow.button.retry">
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
</div>
@@ -258,32 +360,3 @@ export function HandoffApprovalFlow({
}
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"
/>
);
}
@@ -100,6 +100,13 @@
text-align: center;
}
.browserStatusTitle {
color: var(--text-primary);
font-size: 1rem;
font-weight: 700;
line-height: 1.25rem;
}
.browserStatusText {
color: var(--text-secondary);
font-size: 0.875rem;
@@ -116,7 +123,7 @@
padding: 1rem 0.75rem;
}
.browserManualCode {
.browserCode {
max-width: 100%;
padding: 0.75rem 0.875rem;
border: 0.0625rem solid var(--background-modifier-accent);
@@ -140,10 +147,25 @@
font-variant-numeric: tabular-nums;
}
.browserTimerExpired {
color: var(--status-danger);
font-size: 0.8125rem;
.browserTextButton {
padding: 0.125rem 0.25rem;
border: none;
border-radius: 0.25rem;
background: none;
color: var(--text-link);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.browserTextButton:hover:not(:disabled) {
text-decoration: underline;
}
.browserTextButton:disabled {
cursor: default;
opacity: 0.5;
}
.browserFooter {
@@ -4,13 +4,9 @@ import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapsh
import {resolveSnapshotInstanceDomain} from '@app/features/auth/AccountDisplayUtils';
import loginStyles from '@app/features/auth/components/pages/LoginPage.module.css';
import styles from '@app/features/auth/flow/auth_login_core/AuthLoginBrowserStep.module.css';
import {
DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
} from '@app/features/auth/flow/browser_handoff/BrowserHandoffDescriptors';
import {
BrowserLoginHandoffAction,
type BrowserLoginHandoffPendingAction,
type BrowserLoginHandoffController,
useBrowserLoginHandoff,
} from '@app/features/auth/flow/browser_handoff/useBrowserLoginHandoff';
import {
@@ -22,6 +18,7 @@ import {useKnownInstances} from '@app/features/auth/flow/instance_selector/useKn
import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
import {
BACK_DESCRIPTOR,
CANCEL_DESCRIPTOR,
CHANGE_INSTANCE_DESCRIPTOR,
COPIED_DESCRIPTOR,
COPY_CODE_DESCRIPTOR,
@@ -32,12 +29,13 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Button, ButtonVariant} from '@app/features/ui/button/Button';
import {VerifiedConnectionIcon} from '@app/features/ui/components/icons/VerifiedConnectionIcon';
import {Spinner, SpinnerSize} from '@app/features/ui/components/Spinner';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {SteppedCarousel, SteppedCarouselActions} from '@app/features/ui/stepped_carousel/SteppedCarousel';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {flxElementClassName} from '@app/lib/react';
import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
import {DesktopHandoffReturnMethod} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {isOfficialInstanceHost, OFFICIAL_INSTANCE_NAME} from '@fluxer/instance_bootstrap/src/OfficialInstance';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {
@@ -47,27 +45,14 @@ import {
CheckCircleIcon,
ClipboardIcon,
GlobeIcon,
PasswordIcon,
WarningCircleIcon,
} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
import {type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState} from 'react';
const OPEN_BROWSER_DESCRIPTOR = msg({
message: 'Open browser',
comment: 'Button label that opens the browser sign-in handoff page.',
});
const SHOW_CODE_DESCRIPTOR = msg({
message: 'Show code',
comment: 'Button label that opens the manual browser handoff code fallback.',
});
const GENERATE_NEW_CODE_DESCRIPTOR = msg({
message: 'Generate new code',
comment: 'Button label that replaces an expired browser sign-in code with a fresh one.',
});
const BROWSER_CODE_EXPIRED_DESCRIPTOR = msg({
message: 'This code expired.',
comment: 'Status shown under the browser sign-in code once its countdown reaches zero.',
const OPEN_BROWSER_AGAIN_DESCRIPTOR = msg({
message: 'Open browser again',
comment: 'Button in the desktop app that reopens the browser sign-in page while the app waits for the browser.',
});
const BROWSER_WAIT_STOPPED_DESCRIPTOR = msg({
message: "Fluxer stopped checking whether the browser approved this sign-in. It didn't lose your code.",
@@ -78,6 +63,10 @@ const BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR = msg({
comment:
'Explanation shown when the selected instance advertises no web app endpoint. Instance name is interpolated.',
});
const BROWSER_HANDOFF_FAILED_DESCRIPTOR = msg({
message: "Couldn't start signing in with your browser.",
comment: 'Shown in the desktop app when the browser sign-in request could not be created.',
});
const logger = new Logger('AuthLoginBrowserStep');
@@ -86,20 +75,20 @@ export function resolveShouldOfferBrowserStep(): boolean {
}
export const BrowserHandoffStep = Object.freeze({
READY: 'ready',
WAITING: 'waiting',
MANUAL: 'manual',
OTHER_DEVICE: 'other_device',
PROBLEM: 'problem',
} as const);
export type BrowserHandoffStep = (typeof BrowserHandoffStep)[keyof typeof BrowserHandoffStep];
const BROWSER_STEPS: ReadonlyArray<BrowserHandoffStep> = Object.freeze([
BrowserHandoffStep.READY,
BrowserHandoffStep.WAITING,
BrowserHandoffStep.MANUAL,
BrowserHandoffStep.OTHER_DEVICE,
BrowserHandoffStep.PROBLEM,
]);
type ManualReturnStep = typeof BrowserHandoffStep.READY | typeof BrowserHandoffStep.WAITING;
type RetryStep = typeof BrowserHandoffStep.WAITING | typeof BrowserHandoffStep.OTHER_DEVICE;
interface BrowserInstanceIdentity {
readonly domain: string;
@@ -138,331 +127,179 @@ function resolveBrowserInstanceIdentity({
return {domain, isOfficial, name: resolveInstanceLabel(findKnownInstanceName(domain, knownInstances), domain)};
}
function resolveManualReturnStep(step: BrowserHandoffStep): ManualReturnStep {
if (step === BrowserHandoffStep.WAITING) {
return BrowserHandoffStep.WAITING;
function resolveSignInAddress(webAppEndpoint: string): string {
try {
const url = new URL(webAppEndpoint);
return `${url.host}${url.pathname.replace(/\/+$/u, '')}/login?handoff=1`;
} catch {
return `${webAppEndpoint}/login?handoff=1`;
}
return BrowserHandoffStep.READY;
}
function renderCopyCodeIcon(copied: boolean): ReactNode {
if (copied) {
return (
<CheckCircleIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-copy-code-icon.check-circle-icon"
/>
);
}
function HandoffCode({code}: {code: string}): ReactElement {
return (
<ClipboardIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-copy-code-icon.clipboard-icon"
/>
<flx-auth-login-browser-step-code
className={flxElementClassName(styles.browserCode)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.code"
>
{code}
</flx-auth-login-browser-step-code>
);
}
function resolveCopyCodeLabel(i18n: I18n, copied: boolean): string {
if (copied) {
return i18n._(COPIED_DESCRIPTOR);
}
return i18n._(COPY_CODE_DESCRIPTOR);
}
interface BrowserStepActionsProps {
readonly activeBackAction: () => void;
readonly canStart: boolean;
readonly copied: boolean;
readonly copyCode: () => void;
readonly hasCode: boolean;
readonly hasStoppedWaiting: boolean;
readonly isExpired: boolean;
readonly isGenerating: boolean;
readonly onOpenBrowser: () => void;
readonly onRegenerateCode: () => void;
readonly onResumeWaiting: () => void;
readonly onShowCode: () => void;
readonly pendingAction: BrowserLoginHandoffPendingAction;
readonly showBackButton: boolean;
readonly step: BrowserHandoffStep;
}
function BrowserStepActions({
activeBackAction,
canStart,
copied,
copyCode,
hasCode,
hasStoppedWaiting,
isExpired,
isGenerating,
onOpenBrowser,
onRegenerateCode,
onResumeWaiting,
onShowCode,
pendingAction,
showBackButton,
step,
}: BrowserStepActionsProps): ReactElement {
const {i18n} = useLingui();
const handoffButtonState = (action: BrowserLoginHandoffAction): {disabled: boolean; submitting: boolean} => ({
disabled: !canStart || (isGenerating && pendingAction !== action),
submitting: isGenerating && pendingAction === action,
});
const regenerateButton = (
<Button
onClick={onRegenerateCode}
{...handoffButtonState(BrowserLoginHandoffAction.SHOW_CODE)}
leftIcon={
<ArrowClockwiseIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.arrow-clockwise-icon"
/>
}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.generate-new-code"
>
{i18n._(GENERATE_NEW_CODE_DESCRIPTOR)}
</Button>
);
const showCodeButton = (
<Button
variant={ButtonVariant.SECONDARY}
onClick={onShowCode}
{...handoffButtonState(BrowserLoginHandoffAction.SHOW_CODE)}
leftIcon={
<PasswordIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.password-icon"
/>
}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.show-code"
>
{i18n._(SHOW_CODE_DESCRIPTOR)}
</Button>
);
const resumeButton = hasStoppedWaiting ? (
<Button
onClick={onResumeWaiting}
leftIcon={
<ArrowClockwiseIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.resume-icon"
/>
}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
) : null;
let primaryActions: ReactNode;
if (step === BrowserHandoffStep.MANUAL) {
primaryActions = (
<>
{isExpired ? (
regenerateButton
) : (
<Button
onClick={copyCode}
disabled={!hasCode}
leftIcon={renderCopyCodeIcon(copied)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.copy-code"
>
{resolveCopyCodeLabel(i18n, copied)}
</Button>
)}
{resumeButton}
</>
);
} else if (step === BrowserHandoffStep.WAITING) {
primaryActions = (
<>
{showCodeButton}
{resumeButton}
</>
);
} else {
primaryActions = (
<>
{showCodeButton}
<Button
onClick={onOpenBrowser}
{...handoffButtonState(BrowserLoginHandoffAction.OPEN_BROWSER)}
leftIcon={
<ArrowSquareOutIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.arrow-square-out-icon"
/>
}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.open-browser"
>
{i18n._(OPEN_BROWSER_DESCRIPTOR)}
</Button>
</>
);
}
const renderBackAction = (): ReactNode => {
if (!showBackButton) {
return null;
}
return (
<Button
type="button"
variant={ButtonVariant.SECONDARY}
leftIcon={
<ArrowLeftIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-back-action.arrow-left-icon"
/>
}
onClick={activeBackAction}
disabled={isGenerating}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back"
>
{i18n._(BACK_DESCRIPTOR)}
</Button>
);
};
return (
<SteppedCarouselActions
className={styles.browserFooter}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.browser-footer"
>
{renderBackAction()}
{primaryActions}
</SteppedCarouselActions>
);
}
interface BrowserStepContentProps {
readonly canStart: boolean;
readonly displayCode: string;
readonly hasStoppedWaiting: boolean;
interface WaitingContentProps {
readonly handoff: BrowserLoginHandoffController;
readonly instanceName: string;
readonly isExpired: boolean;
readonly remainingSeconds: number | null;
readonly step: BrowserHandoffStep;
readonly onOtherDevice: () => void;
}
function renderManualCodeTimer(i18n: I18n, isExpired: boolean, remainingSeconds: number | null): ReactNode {
if (remainingSeconds == null) {
return null;
}
if (isExpired) {
return (
<flx-auth-login-browser-step-timer
className={flxElementClassName(styles.browserTimerExpired)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-timer-expired"
>
{i18n._(BROWSER_CODE_EXPIRED_DESCRIPTOR)}
</flx-auth-login-browser-step-timer>
);
}
function WaitingContent({handoff, instanceName, onOtherDevice}: WaitingContentProps): ReactElement {
const {i18n} = useLingui();
const showCode = handoff.returnMethod === DesktopHandoffReturnMethod.CODE && handoff.hasCode;
return (
<flx-auth-login-browser-step-timer
className={flxElementClassName(styles.browserTimer)}
aria-live="off"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-timer"
<flx-auth-login-browser-step-status-panel
className={flxElementClassName(styles.browserStatusPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-panel"
>
<Trans>Expires in {remainingSeconds}s</Trans>
</flx-auth-login-browser-step-timer>
{handoff.hasStoppedWaiting ? (
<WarningCircleIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.stopped-waiting-icon"
/>
) : (
<Spinner size={SpinnerSize.MEDIUM} data-flx="auth.flow.auth-login-core.auth-login-browser-step.spinner" />
)}
<flx-auth-login-browser-step-status-title
className={flxElementClassName(styles.browserStatusTitle)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-title"
>
<Trans comment="Title in the desktop app while it waits for the user to sign in on the browser page it opened.">
Continue in your browser
</Trans>
</flx-auth-login-browser-step-status-title>
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-text"
>
{handoff.hasStoppedWaiting ? (
i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR)
) : showCode ? (
<Trans comment="Shown in the desktop app above the code when the browser cannot return the sign-in automatically. instanceName is the server name, such as Fluxer.">
Sign in to {instanceName} in your browser, and check that it shows this code:
</Trans>
) : (
<Trans comment="Shown in the desktop app while it waits for the browser. instanceName is the server name, such as Fluxer.">
Sign in to {instanceName} in your browser and confirm. This app finishes signing in on its own.
</Trans>
)}
</flx-auth-login-browser-step-status-text>
{showCode && !handoff.hasStoppedWaiting ? <HandoffCode code={handoff.displayCode} /> : null}
<FocusRing offset={-2} data-flx="auth.flow.auth-login-core.auth-login-browser-step.focus-ring.other-device">
<button
type="button"
className={styles.browserTextButton}
onClick={onOtherDevice}
disabled={!handoff.canStart}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.other-device"
>
<Trans comment="Text button in the desktop app that shows a code to type into a browser on a different device.">
Signing in on another device?
</Trans>
</button>
</FocusRing>
</flx-auth-login-browser-step-status-panel>
);
}
function BrowserStepContent({
canStart,
displayCode,
hasStoppedWaiting,
instanceName,
isExpired,
remainingSeconds,
step,
}: BrowserStepContentProps): ReactElement {
function OtherDeviceContent({handoff, signInAddress}: {handoff: BrowserLoginHandoffController; signInAddress: string}) {
const {i18n} = useLingui();
if (step === BrowserHandoffStep.READY) {
return (
<flx-auth-login-browser-step-status-panel
className={flxElementClassName(styles.browserStatusPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-panel"
>
{canStart ? (
<ArrowSquareOutIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.arrow-square-out-icon"
/>
) : (
<WarningCircleIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.warning-circle-icon"
/>
)}
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-text"
>
{canStart ? (
<Trans>Open your browser to finish signing in.</Trans>
) : (
i18n._(BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR, {instanceName})
)}
</flx-auth-login-browser-step-status-text>
</flx-auth-login-browser-step-status-panel>
);
}
if (step === BrowserHandoffStep.WAITING) {
return (
<flx-auth-login-browser-step-status-panel
className={flxElementClassName(styles.browserStatusPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-panel--2"
>
{hasStoppedWaiting ? (
<WarningCircleIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.stopped-waiting-icon"
/>
) : (
<Spinner
size={SpinnerSize.MEDIUM}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.spinner"
/>
)}
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-text--2"
>
{hasStoppedWaiting ? i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR) : <Trans>Waiting for browser approval.</Trans>}
</flx-auth-login-browser-step-status-text>
</flx-auth-login-browser-step-status-panel>
);
}
const remainingSeconds = handoff.remainingSeconds;
return (
<flx-auth-login-browser-step-manual-panel
className={flxElementClassName(styles.browserManualPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-manual-panel"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-panel"
>
<flx-auth-login-browser-step-manual-code
className={flxElementClassName(styles.browserManualCode)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-manual-code"
<flx-auth-login-browser-step-status-title
className={flxElementClassName(styles.browserStatusTitle)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-title"
>
{displayCode}
</flx-auth-login-browser-step-manual-code>
{renderManualCodeTimer(i18n, isExpired, remainingSeconds)}
<Trans comment="Title in the desktop app above the code the user types into a browser on another device.">
Enter this code in your browser
</Trans>
</flx-auth-login-browser-step-status-title>
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-text"
>
<Trans comment="One line explanation in the desktop app above the code. signInAddress is the web address of the sign-in page, such as web.fluxer.app/login?handoff=1.">
On your other device, sign in at {signInAddress} and enter this code when asked.
</Trans>
</flx-auth-login-browser-step-status-text>
{handoff.hasCode ? (
<HandoffCode code={handoff.displayCode} />
) : (
<Spinner size={SpinnerSize.MEDIUM} data-flx="auth.flow.auth-login-core.auth-login-browser-step.code-spinner" />
)}
<flx-auth-login-browser-step-timer
className={flxElementClassName(styles.browserTimer)}
aria-live="off"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.timer"
>
{remainingSeconds == null ? ' ' : <Trans>Expires in {remainingSeconds}s</Trans>}
</flx-auth-login-browser-step-timer>
{handoff.hasStoppedWaiting ? (
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-stopped"
>
{i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR)}
</flx-auth-login-browser-step-status-text>
) : null}
</flx-auth-login-browser-step-manual-panel>
);
}
function ProblemContent({message}: {message: string}): ReactElement {
return (
<flx-auth-login-browser-step-status-panel
className={flxElementClassName(styles.browserStatusPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-panel"
>
<WarningCircleIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-icon"
/>
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="alert"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-text"
>
{message}
</flx-auth-login-browser-step-status-text>
</flx-auth-login-browser-step-status-panel>
);
}
function ButtonIcon({icon}: {icon: 'open' | 'retry' | 'back' | 'copy' | 'copied'}): ReactElement {
const props = {size: remFromPx(16), weight: 'bold' as const};
switch (icon) {
case 'open':
return <ArrowSquareOutIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.open" />;
case 'retry':
return <ArrowClockwiseIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.retry" />;
case 'back':
return <ArrowLeftIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.back" />;
case 'copy':
return <ClipboardIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.copy" />;
case 'copied':
return <CheckCircleIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.copied" />;
}
}
interface AuthLoginBrowserStepProps {
readonly extraTopContent?: ReactNode;
readonly prefillIdentifier?: string | null;
@@ -489,89 +326,86 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
showBackButton = true,
}: AuthLoginBrowserStepProps) {
const {i18n} = useLingui();
const [step, setStep] = useState<BrowserHandoffStep>(BrowserHandoffStep.READY);
const [manualReturnStep, setManualReturnStep] = useState<ManualReturnStep>(BrowserHandoffStep.READY);
const [step, setStep] = useState<BrowserHandoffStep>(BrowserHandoffStep.WAITING);
const [retryStep, setRetryStep] = useState<RetryStep>(BrowserHandoffStep.WAITING);
const knownInstances = useKnownInstances();
const instanceIdentity = resolveBrowserInstanceIdentity({snapshot: runtimeSnapshot, knownInstances});
const handoffMessages = useMemo(
() => ({
desktopHandoffUnavailable: DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
expired: DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
}),
[],
);
const handleExpired = useCallback(() => {
setManualReturnStep(BrowserHandoffStep.READY);
setStep(BrowserHandoffStep.READY);
const handleEnded = useCallback(() => {
setStep(BrowserHandoffStep.PROBLEM);
}, []);
const {
canStart,
copied,
copyCode,
displayCode,
error,
expireCurrentSession,
hasCode,
hasStoppedWaiting,
isCurrentSessionExpired,
isExpired,
isGenerating,
openBrowser,
pendingAction,
regenerateCode,
remainingSeconds,
resumeWaiting,
showManualCode,
} = useBrowserLoginHandoff({
const handoff = useBrowserLoginHandoff({
runtimeSnapshot,
prefillIdentifier,
messages: handoffMessages,
onExpired: handleExpired,
onEnded: handleEnded,
onSuccess,
});
const {canStart, openBrowser, regenerateCode, reset, showManualCode} = handoff;
const showProblem = useCallback((from: RetryStep) => {
setRetryStep(from);
setStep(BrowserHandoffStep.PROBLEM);
}, []);
const handleOpenBrowser = useCallback(() => {
setStep(BrowserHandoffStep.WAITING);
openBrowser()
.then((opened) => {
if (opened) {
setStep(BrowserHandoffStep.WAITING);
if (!opened) {
showProblem(BrowserHandoffStep.WAITING);
}
})
.catch((caught) => {
logger.error('Failed to open the browser sign-in page', caught);
});
}, [openBrowser]);
const handleShowCode = useCallback(() => {
const returnStep = resolveManualReturnStep(step);
}, [openBrowser, showProblem]);
const handleOtherDevice = useCallback(() => {
setRetryStep(BrowserHandoffStep.OTHER_DEVICE);
setStep(BrowserHandoffStep.OTHER_DEVICE);
showManualCode()
.then((shown) => {
if (shown) {
setManualReturnStep(returnStep);
setStep(BrowserHandoffStep.MANUAL);
if (!shown) {
showProblem(BrowserHandoffStep.OTHER_DEVICE);
}
})
.catch((caught) => {
logger.error('Failed to reveal the browser sign-in code', caught);
logger.error('Failed to show the browser sign-in code', caught);
});
}, [showManualCode, step]);
const handleRegenerateCode = useCallback(() => {
regenerateCode()
.then((generated) => {
if (!generated) {
setStep(BrowserHandoffStep.READY);
}
})
.catch((caught) => {
logger.error('Failed to regenerate the browser sign-in code', caught);
});
}, [regenerateCode]);
const handleBackFromManualCode = useCallback(() => {
if (isCurrentSessionExpired()) {
expireCurrentSession();
}, [showManualCode, showProblem]);
const handleRetry = useCallback(() => {
if (retryStep === BrowserHandoffStep.OTHER_DEVICE) {
setStep(BrowserHandoffStep.OTHER_DEVICE);
regenerateCode()
.then((generated) => {
if (!generated) {
showProblem(BrowserHandoffStep.OTHER_DEVICE);
}
})
.catch((caught) => {
logger.error('Failed to create a new browser sign-in code', caught);
});
return;
}
setStep(manualReturnStep);
}, [expireCurrentSession, isCurrentSessionExpired, manualReturnStep]);
const activeBackAction = step === BrowserHandoffStep.MANUAL ? handleBackFromManualCode : onBack;
handleOpenBrowser();
}, [handleOpenBrowser, regenerateCode, retryStep, showProblem]);
const handleCancel = useCallback(() => {
reset();
onBack();
}, [onBack, reset]);
const handleBackToWaiting = useCallback(() => {
setRetryStep(BrowserHandoffStep.WAITING);
setStep(BrowserHandoffStep.WAITING);
}, []);
const startedRef = useRef(false);
useEffect(() => {
if (startedRef.current) {
return;
}
startedRef.current = true;
if (canStart) {
handleOpenBrowser();
} else {
setStep(BrowserHandoffStep.PROBLEM);
}
}, [canStart, handleOpenBrowser]);
const activeBackAction = step === BrowserHandoffStep.OTHER_DEVICE ? handleBackToWaiting : handleCancel;
useEffect(() => {
if (onBackActionChange == null) {
return;
@@ -579,6 +413,124 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
onBackActionChange(activeBackAction);
return () => onBackActionChange(null);
}, [activeBackAction, onBackActionChange]);
const problemMessage = canStart
? (handoff.error ?? i18n._(BROWSER_HANDOFF_FAILED_DESCRIPTOR))
: i18n._(BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR, {instanceName: instanceIdentity.name});
const isOpening = handoff.isGenerating && handoff.pendingAction === BrowserLoginHandoffAction.OPEN_BROWSER;
const isMakingCode = handoff.isGenerating && handoff.pendingAction === BrowserLoginHandoffAction.SHOW_CODE;
let content: ReactNode;
let actions: ReactNode;
if (step === BrowserHandoffStep.OTHER_DEVICE) {
content = (
<OtherDeviceContent
handoff={handoff}
signInAddress={resolveSignInAddress(runtimeSnapshot.webAppEndpoint)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-content"
/>
);
actions = (
<>
{showBackButton ? (
<Button
variant={ButtonVariant.SECONDARY}
leftIcon={<ButtonIcon icon="back" />}
onClick={handleBackToWaiting}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back-to-waiting"
>
{i18n._(BACK_DESCRIPTOR)}
</Button>
) : null}
{handoff.hasStoppedWaiting ? (
<Button
leftIcon={<ButtonIcon icon="retry" />}
onClick={handoff.resumeWaiting}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
) : (
<Button
leftIcon={<ButtonIcon icon={handoff.copied ? 'copied' : 'copy'} />}
onClick={handoff.copyCode}
disabled={!handoff.hasCode}
submitting={isMakingCode}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.copy-code"
>
{i18n._(handoff.copied ? COPIED_DESCRIPTOR : COPY_CODE_DESCRIPTOR)}
</Button>
)}
</>
);
} else if (step === BrowserHandoffStep.PROBLEM) {
content = (
<ProblemContent
message={problemMessage}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-content"
/>
);
actions = (
<>
{showBackButton ? (
<Button
variant={ButtonVariant.SECONDARY}
leftIcon={<ButtonIcon icon="back" />}
onClick={handleCancel}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back"
>
{i18n._(BACK_DESCRIPTOR)}
</Button>
) : null}
{canStart ? (
<Button
leftIcon={<ButtonIcon icon="retry" />}
onClick={handleRetry}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.retry"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
) : null}
</>
);
} else {
content = (
<WaitingContent
handoff={handoff}
instanceName={instanceIdentity.name}
onOtherDevice={handleOtherDevice}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-content"
/>
);
actions = (
<>
<Button
variant={ButtonVariant.SECONDARY}
onClick={handleCancel}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.cancel"
>
{i18n._(CANCEL_DESCRIPTOR)}
</Button>
{handoff.hasStoppedWaiting ? (
<Button
leftIcon={<ButtonIcon icon="retry" />}
onClick={handoff.resumeWaiting}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
) : (
<Button
leftIcon={<ButtonIcon icon="open" />}
onClick={handleOpenBrowser}
submitting={isOpening}
disabled={!canStart}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.open-browser"
>
{i18n._(OPEN_BROWSER_AGAIN_DESCRIPTOR)}
</Button>
)}
</>
);
}
return (
<flx-auth-login-browser-step
className="flx-element"
@@ -673,49 +625,19 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
</Button>
)}
</flx-auth-login-browser-step-instance-row>
{error != null && error.length > 0 ? (
<div
className={loginStyles.loginNotice}
role="alert"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.alert"
>
{error}
</div>
) : null}
<SteppedCarousel
step={step}
steps={BROWSER_STEPS}
focusOnStepChange
data-flx="auth.flow.auth-login-core.auth-login-browser-step.carousel"
>
<BrowserStepContent
canStart={canStart}
displayCode={displayCode}
hasStoppedWaiting={hasStoppedWaiting}
instanceName={instanceIdentity.name}
isExpired={isExpired}
remainingSeconds={remainingSeconds}
step={step}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content"
/>
<BrowserStepActions
activeBackAction={activeBackAction}
canStart={canStart}
copied={copied}
copyCode={copyCode}
hasCode={hasCode}
hasStoppedWaiting={hasStoppedWaiting}
isExpired={isExpired}
isGenerating={isGenerating}
onOpenBrowser={handleOpenBrowser}
onRegenerateCode={handleRegenerateCode}
onResumeWaiting={resumeWaiting}
onShowCode={handleShowCode}
pendingAction={pendingAction}
showBackButton={showBackButton}
step={step}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions"
/>
{content}
<SteppedCarouselActions
className={styles.browserFooter}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-footer"
>
{actions}
</SteppedCarouselActions>
</SteppedCarousel>
</flx-auth-login-browser-step-pane>
</flx-auth-login-browser-step>
@@ -6,12 +6,12 @@ export const DesktopHandoffMode = Object.freeze({
IDLE: 'idle',
SELECTING: 'selecting',
LOGIN: 'login',
WARNING: 'warning',
CODE_INPUT: 'code_input',
FETCHING_INFO: 'fetching_info',
APPROVING: 'approving',
COMPLETING: 'completing',
DONE: 'done',
DENIED: 'denied',
ERROR: 'error',
} as const);
@@ -19,12 +19,12 @@ export type DesktopHandoffMode = (typeof DesktopHandoffMode)[keyof typeof Deskto
export function isApprovalFlowMode(mode: DesktopHandoffMode): boolean {
switch (mode) {
case DesktopHandoffMode.WARNING:
case DesktopHandoffMode.CODE_INPUT:
case DesktopHandoffMode.FETCHING_INFO:
case DesktopHandoffMode.APPROVING:
case DesktopHandoffMode.COMPLETING:
case DesktopHandoffMode.DONE:
case DesktopHandoffMode.DENIED:
case DesktopHandoffMode.ERROR:
return true;
case DesktopHandoffMode.IDLE:
@@ -1,18 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
import type {
DesktopHandoffInfoResponse,
DesktopHandoffReturnMethod,
} from '@app/features/auth/commands/AuthenticationCommands';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/AuthLoginStepTypes';
import type {UserData} from '@app/features/auth/state/AccountStorage';
import {type InstanceHTTPTarget, instanceTargetFromSnapshot} from '@app/features/platform/transport/InstanceHTTP';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as FormUtils from '@app/lib/forms';
import {formatDesktopHandoffCode, parseDesktopHandoffCode} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {useCallback, useMemo, useRef, useState} from 'react';
const INVALID_OR_EXPIRED_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Invalid or expired code. Try again.',
comment: 'Desktop handoff flow error shown when the entered pairing code is invalid or expired.',
const HANDOFF_REQUEST_EXPIRED_DESCRIPTOR = msg({
message: 'This sign-in request expired or was cancelled in the {productName} app. Start again from the app.',
comment:
'Error in the browser when the sign-in request from the desktop app no longer exists, because it expired or the app cancelled it. productName is the app name, such as Fluxer.',
});
const TYPED_CODE_NOT_FOUND_DESCRIPTOR = msg({
message: 'That code is not valid on {instanceHost}. Check the code in the {productName} app and try again.',
comment:
'Error in the browser when a code typed from the app is not found. instanceHost is the server the browser checked, such as api.fluxer.app. productName is the app name, such as Fluxer.',
});
const COULDN_T_VERIFY_THAT_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: "Couldn't verify that code. Try again.",
@@ -22,11 +35,66 @@ const COULDN_T_COMPLETE_SIGN_IN_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: "Couldn't complete sign-in. Try again.",
comment: 'Desktop handoff flow error shown when finalizing the sign-in fails after a valid code.',
});
const INSTANCE_MISMATCH_DESCRIPTOR = msg({
message:
'This account is on {accountHost}, but the {productName} app is signing in to {requestHost}. Choose an account on {requestHost}.',
comment:
'Error in the browser when the chosen account belongs to a different server than the app that asked to sign in. accountHost and requestHost are server addresses. productName is the app name, such as Fluxer.',
});
const logger = new Logger('useDesktopHandoffFlow');
export const DesktopHandoffCodeSource = Object.freeze({
LINK: 'link',
TYPED: 'typed',
} as const);
export type DesktopHandoffCodeSource = (typeof DesktopHandoffCodeSource)[keyof typeof DesktopHandoffCodeSource];
export function isHandoffRequest(params: URLSearchParams): boolean {
return params.get('handoff') === '1' || params.get('desktop_handoff') === '1';
}
export interface DesktopHandoffRequest {
readonly code: string | null;
readonly apiOrigin: string | null;
readonly identifier: string | null;
}
function readOrigin(value: string | null | undefined): string | null {
if (value == null || value.length === 0) {
return null;
}
try {
return new URL(value).origin;
} catch {
return null;
}
}
export function readDesktopHandoffRequest(search: string): DesktopHandoffRequest {
const params = new URLSearchParams(search);
const code = parseDesktopHandoffCode(params.get('code') ?? '');
return {
code: code == null ? null : formatDesktopHandoffCode(code),
apiOrigin: readOrigin(params.get('api')),
identifier: params.get('email') ?? params.get('login'),
};
}
function hostOf(endpoint: string): string {
try {
return new URL(endpoint).host;
} catch {
return endpoint;
}
}
function matchesIdentifier(identifier: string, userData: UserData): boolean {
const wanted = identifier.trim().toLowerCase();
return wanted === userData.username.toLowerCase() || wanted === userData.email?.toLowerCase();
}
interface Options {
enabled: boolean;
hasStoredAccounts: boolean;
@@ -37,6 +105,7 @@ export interface DesktopHandoffAccountCredentials {
readonly token: string;
readonly userId: string;
readonly runtimeSnapshot: RuntimeConfigSnapshot;
readonly userData?: UserData;
}
export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}: Options) {
@@ -46,35 +115,26 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
if (initialMode) return initialMode;
return hasStoredAccounts ? DesktopHandoffMode.SELECTING : DesktopHandoffMode.LOGIN;
}, [enabled, hasStoredAccounts, initialMode]);
const [request] = useState(() => readDesktopHandoffRequest(window.location.search));
const [mode, setMode] = useState<DesktopHandoffMode>(derivedInitial);
const [error, setError] = useState<string | null>(null);
const [clientInfo, setClientInfo] = useState<DesktopHandoffInfoResponse['client_info']>(null);
const [handoffCode, setHandoffCode] = useState<string | null>(null);
const [returnMethod, setReturnMethod] = useState<DesktopHandoffReturnMethod | null>(null);
const [codeSource, setCodeSource] = useState<DesktopHandoffCodeSource | null>(null);
const [returnUrl, setReturnUrl] = useState<string | null>(null);
const [account, setAccount] = useState<UserData | null>(null);
const tokenRef = useRef<string | null>(null);
const userIdRef = useRef<string | null>(null);
const operationRevisionRef = useRef(0);
const targetRef = useRef<InstanceHTTPTarget | null>(null);
const start = useCallback(
({token, userId, runtimeSnapshot}: DesktopHandoffAccountCredentials) => {
if (!enabled) return;
operationRevisionRef.current += 1;
tokenRef.current = token;
userIdRef.current = userId;
targetRef.current = instanceTargetFromSnapshot(runtimeSnapshot);
setError(null);
setHandoffCode(null);
setClientInfo(null);
setMode(DesktopHandoffMode.WARNING);
},
[enabled],
);
const proceedToCodeInput = useCallback(() => {
operationRevisionRef.current += 1;
setMode(DesktopHandoffMode.CODE_INPUT);
setError(null);
const linkCodeUsedRef = useRef(false);
const failWith = useCallback((message: string) => {
setMode(DesktopHandoffMode.ERROR);
setError(message);
}, []);
const submitCode = useCallback(
async (code: string) => {
const lookUpCode = useCallback(
async (code: string, source: DesktopHandoffCodeSource) => {
const token = tokenRef.current;
const target = targetRef.current;
if (token == null || token.length === 0 || target == null) return;
@@ -82,61 +142,118 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
setMode(DesktopHandoffMode.FETCHING_INFO);
setError(null);
setHandoffCode(code);
setCodeSource(source);
try {
const info = await AuthenticationCommands.fetchDesktopHandoffInfo({code, token, target});
if (operationRevision !== operationRevisionRef.current) return;
if (info.status === 'expired') {
setMode(DesktopHandoffMode.ERROR);
setError(i18n._(INVALID_OR_EXPIRED_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR));
failWith(
source === DesktopHandoffCodeSource.LINK
? i18n._(HANDOFF_REQUEST_EXPIRED_DESCRIPTOR, {productName: PRODUCT_NAME})
: i18n._(TYPED_CODE_NOT_FOUND_DESCRIPTOR, {
instanceHost: hostOf(target.apiEndpoint),
productName: PRODUCT_NAME,
}),
);
return;
}
setClientInfo(info.client_info);
setReturnMethod(
source === DesktopHandoffCodeSource.LINK && info.return_method === 'deep_link' ? 'deep_link' : 'code',
);
setMode(DesktopHandoffMode.APPROVING);
} catch (e) {
if (operationRevision !== operationRevisionRef.current) return;
setMode(DesktopHandoffMode.ERROR);
setError(
failWith(
e && typeof e === 'object' && 'body' in e
? FormUtils.extractErrorMessage(i18n, e)
: i18n._(COULDN_T_VERIFY_THAT_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR),
);
}
},
[i18n],
[failWith, i18n],
);
const start = useCallback(
({token, userId, runtimeSnapshot, userData}: DesktopHandoffAccountCredentials) => {
if (!enabled) return;
operationRevisionRef.current += 1;
tokenRef.current = token;
userIdRef.current = userId;
targetRef.current = instanceTargetFromSnapshot(runtimeSnapshot);
setAccount(userData ?? null);
setError(null);
setHandoffCode(null);
setClientInfo(null);
setReturnMethod(null);
setReturnUrl(null);
const accountOrigin = readOrigin(runtimeSnapshot.apiEndpoint);
if (request.apiOrigin != null && accountOrigin != null && request.apiOrigin !== accountOrigin) {
failWith(
i18n._(INSTANCE_MISMATCH_DESCRIPTOR, {
accountHost: hostOf(accountOrigin),
requestHost: hostOf(request.apiOrigin),
productName: PRODUCT_NAME,
}),
);
return;
}
if (request.code != null && !linkCodeUsedRef.current) {
linkCodeUsedRef.current = true;
void lookUpCode(request.code, DesktopHandoffCodeSource.LINK);
return;
}
setMode(DesktopHandoffMode.CODE_INPUT);
},
[enabled, failWith, i18n, lookUpCode, request],
);
const submitCode = useCallback((code: string) => lookUpCode(code, DesktopHandoffCodeSource.TYPED), [lookUpCode]);
const approve = useCallback(async () => {
const token = tokenRef.current;
const userId = userIdRef.current;
const target = targetRef.current;
if (!handoffCode || !token || !userId || target == null) return;
if (!handoffCode || !token || !userId || target == null || returnMethod == null) return;
const operationRevision = ++operationRevisionRef.current;
setMode(DesktopHandoffMode.COMPLETING);
setError(null);
try {
await AuthenticationCommands.completeDesktopHandoff({
const completedReturnUrl = await AuthenticationCommands.completeDesktopHandoff({
code: handoffCode,
token,
userId,
returnMethod,
target,
});
if (operationRevision !== operationRevisionRef.current) return;
setReturnUrl(completedReturnUrl);
setMode(DesktopHandoffMode.DONE);
if (completedReturnUrl != null) {
window.location.assign(completedReturnUrl);
}
} catch (e) {
if (operationRevision !== operationRevisionRef.current) return;
setMode(DesktopHandoffMode.ERROR);
setError(
failWith(
e && typeof e === 'object' && 'body' in e
? FormUtils.extractErrorMessage(i18n, e)
: i18n._(COULDN_T_COMPLETE_SIGN_IN_PLEASE_TRY_AGAIN_DESCRIPTOR),
);
}
}, [handoffCode, i18n]);
}, [failWith, handoffCode, i18n, returnMethod]);
const deny = useCallback(() => {
const target = targetRef.current;
const code = handoffCode;
operationRevisionRef.current += 1;
setHandoffCode(null);
setClientInfo(null);
setMode(DesktopHandoffMode.CODE_INPUT);
}, []);
setMode(DesktopHandoffMode.DENIED);
if (code == null || target == null) return;
AuthenticationCommands.denyDesktopHandoff({code, target}).catch((caught) => {
logger.warn('Failed to tell the server that the sign-in request was declined', caught);
});
}, [handoffCode]);
const reopenApp = useCallback(() => {
if (returnUrl != null) {
window.location.assign(returnUrl);
}
}, [returnUrl]);
const switchToLogin = useCallback(() => {
operationRevisionRef.current += 1;
setMode(DesktopHandoffMode.LOGIN);
@@ -147,22 +264,34 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
setError(null);
setHandoffCode(null);
setClientInfo(null);
setReturnMethod(null);
setReturnUrl(null);
setAccount(null);
tokenRef.current = null;
userIdRef.current = null;
targetRef.current = null;
setMode(hasStoredAccounts ? DesktopHandoffMode.SELECTING : DesktopHandoffMode.LOGIN);
}, [hasStoredAccounts]);
const isWrongAccount =
request.identifier != null && account != null && !matchesIdentifier(request.identifier, account);
return {
mode,
error,
clientInfo,
handoffCode,
codeSource,
returnMethod,
returnUrl,
account,
requestedIdentifier: isWrongAccount ? request.identifier : null,
start,
proceedToCodeInput,
submitCode,
approve,
deny,
reopenApp,
switchToLogin,
retry,
};
}
export type DesktopHandoffFlow = ReturnType<typeof useDesktopHandoffFlow>;
@@ -7,6 +7,11 @@ export const DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR = msg({
comment: 'Error shown when the browser sign-in handoff cannot run in the current environment.',
});
export const DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR = msg({
message: 'That sign-in code expired. Try again.',
comment: 'Error shown when a browser sign-in handoff code expires before it is approved.',
message: 'The sign-in request expired before your browser approved it.',
comment:
'Error shown in the desktop app when a browser sign-in request expires before it is approved in the browser.',
});
export const DESKTOP_HANDOFF_DENIED_DESCRIPTOR = msg({
message: 'The sign-in was cancelled in your browser.',
comment: 'Error shown in the desktop app when the user pressed Cancel on the browser sign-in page.',
});
@@ -5,7 +5,7 @@ import type {UserData} from '@app/features/auth/state/AccountStorage';
import type {InstanceHTTPTarget} from '@app/features/platform/transport/InstanceHTTP';
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
import type {DesktopHandoffAPI} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {DesktopHandoffReturnMethod, DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
export interface BrowserLoginHandoffTarget {
readonly webAppEndpoint: string;
@@ -16,6 +16,7 @@ export interface BrowserLoginHandoffSession {
readonly code: string;
readonly expiresAt: string;
readonly pollSecret: string | null;
readonly returnMethod: DesktopHandoffReturnMethod;
readonly target: BrowserLoginHandoffTarget;
}
@@ -47,6 +48,9 @@ function normalizeHandoffStatus(status: string): DesktopHandoffStatus {
if (status === DesktopHandoffStatus.EXPIRED) {
return DesktopHandoffStatus.EXPIRED;
}
if (status === DesktopHandoffStatus.DENIED) {
return DesktopHandoffStatus.DENIED;
}
return DesktopHandoffStatus.PENDING;
}
@@ -55,7 +59,13 @@ function createRendererHandoffTransport(): BrowserLoginHandoffTransport {
kind: BrowserLoginHandoffTransportKind.RENDERER,
initiate: async (target) => {
const result = await AuthenticationCommands.initiateDesktopHandoff(target.instance);
return {code: result.code, expiresAt: result.expires_at, pollSecret: result.poll_secret ?? null, target};
return {
code: result.code,
expiresAt: result.expires_at,
pollSecret: result.poll_secret ?? null,
returnMethod: DesktopHandoffReturnMethod.CODE,
target,
};
},
status: async (session) => {
const result = await AuthenticationCommands.pollDesktopHandoffStatus(
@@ -86,6 +96,10 @@ function createIpcHandoffTransport(api: DesktopHandoffAPI): BrowserLoginHandoffT
code: session.code,
expiresAt: session.expiresAt,
pollSecret: null,
returnMethod:
session.returnMethod === DesktopHandoffReturnMethod.DEEP_LINK
? DesktopHandoffReturnMethod.DEEP_LINK
: DesktopHandoffReturnMethod.CODE,
target: {webAppEndpoint: session.instance.webAppEndpoint, instance: target.instance},
};
},
@@ -88,6 +88,7 @@ function createDesktopHandoffAPI(): DesktopHandoffAPI & {
instance,
code: CODE,
expiresAt: new Date(Date.now() + 300_000).toISOString(),
returnMethod: 'deep_link',
};
return session;
},
@@ -107,17 +108,17 @@ let root: Root;
let controller: Controller | null = null;
function Probe({
onExpired,
onEnded,
onSuccess,
runtimeSnapshot,
}: {
onExpired: () => void;
onEnded: () => void;
onSuccess: (payload: {token: string; userId: string}) => Promise<void> | void;
runtimeSnapshot: typeof RUNTIME_SNAPSHOT;
}) {
controller = useBrowserLoginHandoff({
runtimeSnapshot,
onExpired,
onEnded,
onSuccess,
});
return null;
@@ -133,10 +134,10 @@ function requireController(): Controller {
async function mountProbe(
onSuccess: (payload: {token: string; userId: string}) => Promise<void> | void,
runtimeSnapshot: typeof RUNTIME_SNAPSHOT = RUNTIME_SNAPSHOT,
onExpired: () => void = () => {},
onEnded: () => void = () => {},
): Promise<void> {
await act(async () => {
root.render(createElement(Probe, {onExpired, onSuccess, runtimeSnapshot}));
root.render(createElement(Probe, {onEnded, onSuccess, runtimeSnapshot}));
});
}
@@ -217,7 +218,7 @@ describe('browser sign-in against a deployed desktop shell', () => {
expect(harness.initiateDesktopHandoff).toHaveBeenCalledTimes(1);
expect(harness.navigateToExternalURL).toHaveBeenCalledTimes(1);
expect(harness.navigateToExternalURL.mock.calls[0]?.[0]).toBe(
`https://self.hosted.example/login?handoff=1&code=${encodeURIComponent(CODE)}`,
`https://self.hosted.example/login?handoff=1&code=${encodeURIComponent(CODE)}&api=${encodeURIComponent('https://self.hosted.example')}`,
);
await advancePollInterval();
@@ -491,7 +492,7 @@ describe('when the instance reports the code as dead', () => {
expect(harness.pollDesktopHandoffStatus).toHaveBeenCalledTimes(1);
expect(requireController().session).toBeNull();
expect(requireController().code).toBeNull();
expect(requireController().error).toBe('That sign-in code expired. Try again.');
expect(requireController().error).toBe('The sign-in request expired before your browser approved it.');
await advancePollInterval();
await advancePollInterval();
@@ -2,6 +2,7 @@
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {
DESKTOP_HANDOFF_DENIED_DESCRIPTOR,
DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
} from '@app/features/auth/flow/browser_handoff/BrowserHandoffDescriptors';
@@ -17,9 +18,8 @@ import {instanceTargetFromSnapshot} from '@app/features/platform/transport/Insta
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
import {navigateToExternalURL} from '@app/features/ui/utils/NativeUtils';
import {DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {DesktopHandoffReturnMethod, DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {formatDesktopHandoffCode} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
import type {I18n, MessageDescriptor} from '@lingui/core';
import {useLingui} from '@lingui/react/macro';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
@@ -38,16 +38,10 @@ export const BrowserLoginHandoffAction = Object.freeze({
export type BrowserLoginHandoffAction = (typeof BrowserLoginHandoffAction)[keyof typeof BrowserLoginHandoffAction];
export type BrowserLoginHandoffPendingAction = BrowserLoginHandoffAction | null;
export interface BrowserLoginHandoffMessages {
readonly desktopHandoffUnavailable?: MessageDescriptor;
readonly expired?: MessageDescriptor;
}
export interface UseBrowserLoginHandoffOptions {
readonly runtimeSnapshot: RuntimeConfigSnapshot;
readonly prefillIdentifier?: string | null;
readonly messages?: BrowserLoginHandoffMessages;
readonly onExpired: () => void;
readonly onEnded: () => void;
readonly onSuccess: (payload: LoginSuccessPayload) => Promise<void> | void;
}
@@ -63,10 +57,9 @@ export interface BrowserLoginHandoffController {
readonly isGenerating: boolean;
readonly pendingAction: BrowserLoginHandoffPendingAction;
readonly remainingSeconds: number | null;
readonly returnMethod: DesktopHandoffReturnMethod;
readonly session: BrowserLoginHandoffSession | null;
readonly copyCode: () => void;
readonly expireCurrentSession: () => void;
readonly isCurrentSessionExpired: () => boolean;
readonly openBrowser: () => Promise<boolean>;
readonly regenerateCode: () => Promise<boolean>;
readonly reset: () => void;
@@ -97,7 +90,11 @@ function buildBrowserLoginHandoffURL({
prefillIdentifier,
prefillField,
}: BrowserLoginHandoffURLRequest): string {
const params = new URLSearchParams({handoff: '1', code: session.code});
const params = new URLSearchParams({
handoff: '1',
code: session.code,
api: new URL(session.target.instance.apiEndpoint).origin,
});
if (prefillIdentifier != null && prefillIdentifier.length > 0) {
params.set(prefillField, prefillIdentifier);
}
@@ -131,15 +128,10 @@ function useBrowserLoginHandoffCountdown(expiresAt: string | null): number | nul
return remaining;
}
function resolveMessage(i18n: I18n, descriptor: MessageDescriptor | undefined, fallback: MessageDescriptor): string {
return i18n._(descriptor ?? fallback);
}
export function useBrowserLoginHandoff({
runtimeSnapshot,
prefillIdentifier,
messages,
onExpired,
onEnded,
onSuccess,
}: UseBrowserLoginHandoffOptions): BrowserLoginHandoffController {
const {i18n} = useLingui();
@@ -155,10 +147,10 @@ export function useBrowserLoginHandoff({
const completedRef = useRef(false);
const copyResetRef = useRef<number | null>(null);
const onSuccessRef = useRef(onSuccess);
const onExpiredRef = useRef(onExpired);
const onEndedRef = useRef(onEnded);
const i18nRef = useRef(i18n);
onSuccessRef.current = onSuccess;
onExpiredRef.current = onExpired;
onEndedRef.current = onEnded;
i18nRef.current = i18n;
const target = resolveHandoffTarget(runtimeSnapshot);
const targetKey = `${target.instance.apiEndpoint}|${target.webAppEndpoint}`;
@@ -205,9 +197,7 @@ export function useBrowserLoginHandoff({
const requestTarget = targetRef.current;
try {
if (requestTarget.webAppEndpoint.length === 0) {
throw new BrowserLoginHandoffUnavailableError(
resolveMessage(i18n, messages?.desktopHandoffUnavailable, DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR),
);
throw new BrowserLoginHandoffUnavailableError(i18n._(DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR));
}
const nextSession = await transport.initiate(requestTarget);
if (generation !== generationRef.current) {
@@ -225,7 +215,7 @@ export function useBrowserLoginHandoff({
setIsGenerating(false);
}
}
}, [clearCopyFeedbackTimer, i18n, messages?.desktopHandoffUnavailable, transport]);
}, [clearCopyFeedbackTimer, i18n, transport]);
const getCurrentSession = useCallback(async (): Promise<BrowserLoginHandoffSession | null> => {
if (session != null && Date.parse(session.expiresAt) > Date.now()) {
return session;
@@ -233,17 +223,17 @@ export function useBrowserLoginHandoff({
return generateSession();
}, [generateSession, session]);
const expireCurrentSession = useCallback(() => {
clearHandoffSession(resolveMessage(i18n, messages?.expired, DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR));
onExpiredRef.current();
}, [clearHandoffSession, i18n, messages?.expired]);
clearHandoffSession(i18n._(DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR));
onEndedRef.current();
}, [clearHandoffSession, i18n]);
const expireCurrentSessionRef = useRef(expireCurrentSession);
expireCurrentSessionRef.current = expireCurrentSession;
const isCurrentSessionExpired = useCallback(() => {
if (session == null) {
return false;
}
return Date.parse(session.expiresAt) <= Date.now();
}, [session]);
const endDeniedSession = useCallback(() => {
clearHandoffSession(i18n._(DESKTOP_HANDOFF_DENIED_DESCRIPTOR));
onEndedRef.current();
}, [clearHandoffSession, i18n]);
const endDeniedSessionRef = useRef(endDeniedSession);
endDeniedSessionRef.current = endDeniedSession;
const openBrowser = useCallback(async (): Promise<boolean> => {
setPendingAction(BrowserLoginHandoffAction.OPEN_BROWSER);
try {
@@ -362,6 +352,10 @@ export function useBrowserLoginHandoff({
keepPolling = false;
expireCurrentSessionRef.current();
}
if (result.status === DesktopHandoffStatus.DENIED) {
keepPolling = false;
endDeniedSessionRef.current();
}
} catch (caught) {
consecutiveErrors += 1;
if (!disposed) {
@@ -402,10 +396,9 @@ export function useBrowserLoginHandoff({
isGenerating,
pendingAction,
remainingSeconds,
returnMethod: session?.returnMethod ?? DesktopHandoffReturnMethod.CODE,
session,
copyCode,
expireCurrentSession,
isCurrentSessionExpired,
openBrowser,
regenerateCode,
reset,
@@ -380,26 +380,3 @@ export async function pollIpAuthorization(
): Promise<AuthenticationCommands.IpAuthorizationPollResult> {
return AuthenticationCommands.pollIpAuthorization({ticket, target: authCommandTarget(runtimeSnapshot)});
}
export async function initiateDesktopHandoff(runtimeSnapshot: RuntimeConfigSnapshot) {
return AuthenticationCommands.initiateDesktopHandoff(authCommandTarget(runtimeSnapshot));
}
export async function completeDesktopHandoff({
code,
token,
userId,
runtimeSnapshot,
}: {
code: string;
token: string;
userId: string;
runtimeSnapshot: RuntimeConfigSnapshot;
}) {
return AuthenticationCommands.completeDesktopHandoff({
code,
token,
userId,
target: authCommandTarget(runtimeSnapshot),
});
}