mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(desktop): sign in with the browser without typing a code (#3286)
This commit is contained in:
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),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user