Files
fluxer/fluxer_app/src/components/auth/BrowserLoginHandoffModal.tsx
2026-02-17 12:22:36 +00:00

300 lines
9.1 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import styles from '@app/components/auth/BrowserLoginHandoffModal.module.css';
import {Input} from '@app/components/form/Input';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
import {getElectronAPI, openExternalUrl} from '@app/utils/NativeUtils';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
interface LoginSuccessPayload {
token: string;
userId: string;
}
interface BrowserLoginHandoffModalProps {
onSuccess: (payload: LoginSuccessPayload) => Promise<void>;
targetWebAppUrl?: string;
prefillEmail?: string;
}
const CODE_LENGTH = 8;
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{8}$/;
const formatCodeForDisplay = (raw: string): string => {
const cleaned = raw
.replace(/[^A-Za-z0-9]/g, '')
.toUpperCase()
.slice(0, CODE_LENGTH);
if (cleaned.length <= 4) {
return cleaned;
}
return `${cleaned.slice(0, 4)}-${cleaned.slice(4)}`;
};
const extractRawCode = (formatted: string): string => {
return formatted
.replace(/[^A-Za-z0-9]/g, '')
.toUpperCase()
.slice(0, CODE_LENGTH);
};
function normalizeInstanceOrigin(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error('Instance URL is required');
}
const candidate = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
const url = new URL(candidate);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('Instance URL must use http or https');
}
return url.origin;
}
const BrowserLoginHandoffModal = observer(
({onSuccess, targetWebAppUrl, prefillEmail}: BrowserLoginHandoffModalProps) => {
const {i18n} = useLingui();
const electronApi = getElectronAPI();
const switchInstanceUrl = electronApi?.switchInstanceUrl;
const canSwitchInstanceUrl = typeof switchInstanceUrl === 'function';
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
const [instanceUrl, setInstanceUrl] = useState(() => targetWebAppUrl ?? currentWebAppUrl);
const [instanceUrlError, setInstanceUrlError] = useState<string | null>(null);
const [code, setCode] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const switchingInstanceRef = useRef(false);
const instanceUrlHelper = useMemo(
() => (canSwitchInstanceUrl ? i18n._(msg`The URL of the Fluxer instance you want to sign in to.`) : null),
[canSwitchInstanceUrl, i18n],
);
const handleSubmit = useCallback(
async (rawCode: string) => {
if (!VALID_CODE_PATTERN.test(rawCode)) {
return;
}
setIsSubmitting(true);
setError(null);
setInstanceUrlError(null);
try {
if (canSwitchInstanceUrl) {
const trimmedInstanceUrl = instanceUrl.trim();
if (trimmedInstanceUrl) {
let instanceOrigin: string;
try {
instanceOrigin = normalizeInstanceOrigin(trimmedInstanceUrl);
} catch {
setInstanceUrlError(
i18n._(msg`Invalid instance URL. Try something like "example.com" or "https://example.com".`),
);
return;
}
if (instanceOrigin !== window.location.origin) {
try {
switchingInstanceRef.current = true;
await switchInstanceUrl({
instanceUrl: instanceOrigin,
desktopHandoffCode: rawCode,
});
} catch (switchError) {
switchingInstanceRef.current = false;
const detail = switchError instanceof Error ? switchError.message : String(switchError);
setInstanceUrlError(detail);
}
return;
}
}
}
const result = await AuthenticationActionCreators.pollDesktopHandoffStatus(rawCode);
if (result.status === 'completed' && result.token && result.user_id) {
await onSuccess({token: result.token, userId: result.user_id});
ModalActionCreators.pop();
return;
}
if (result.status === 'pending') {
setError(i18n._(msg`This code hasn't been used yet. Please complete login in your browser first.`));
} else {
setError(i18n._(msg`Invalid or expired code. Please try again.`));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
if (!switchingInstanceRef.current) {
setIsSubmitting(false);
}
}
},
[canSwitchInstanceUrl, i18n, instanceUrl, onSuccess, switchInstanceUrl],
);
const handleInstanceUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInstanceUrl(e.target.value);
setInstanceUrlError(null);
}, []);
const handleCodeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const rawCode = extractRawCode(e.target.value);
setCode(rawCode);
setError(null);
setInstanceUrlError(null);
if (VALID_CODE_PATTERN.test(rawCode)) {
void handleSubmit(rawCode);
}
},
[handleSubmit],
);
const handleOpenBrowser = useCallback(async () => {
const fallbackUrl = targetWebAppUrl || currentWebAppUrl;
let baseUrl = fallbackUrl;
if (canSwitchInstanceUrl && instanceUrl.trim()) {
try {
baseUrl = normalizeInstanceOrigin(instanceUrl);
} catch {
setInstanceUrlError(
i18n._(msg`Invalid instance URL. Try something like "example.com" or "https://example.com".`),
);
return;
}
}
const loginUrl = new URL('/login', baseUrl);
loginUrl.searchParams.set('desktop_handoff', '1');
if (prefillEmail) {
loginUrl.searchParams.set('email', prefillEmail);
}
await openExternalUrl(loginUrl.toString());
}, [canSwitchInstanceUrl, currentWebAppUrl, i18n, instanceUrl, prefillEmail, targetWebAppUrl]);
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<Modal.Root size="small" centered onClose={ModalActionCreators.pop}>
<Modal.Header title={i18n._(msg`Add account`)} />
<Modal.Content contentClassName={styles.content}>
<p className={styles.description}>
<Trans>Log in using your browser, then enter the code shown to add the account.</Trans>
</p>
{canSwitchInstanceUrl ? (
<div className={styles.codeInputSection}>
<Input
label={i18n._(msg`Instance URL`)}
value={instanceUrl}
onChange={handleInstanceUrlChange}
error={instanceUrlError ?? undefined}
disabled={isSubmitting}
autoComplete="url"
placeholder="example.com"
footer={
instanceUrlHelper && !instanceUrlError ? (
<p className={styles.inputHelper}>{instanceUrlHelper}</p>
) : null
}
/>
</div>
) : null}
<div className={styles.codeInputSection}>
<Input
ref={inputRef}
label={i18n._(msg`Login code`)}
value={formatCodeForDisplay(code)}
onChange={handleCodeChange}
error={error ?? undefined}
disabled={isSubmitting}
autoComplete="off"
/>
</div>
{prefillEmail ? (
<p className={styles.prefillHint}>
<Trans>We will prefill {prefillEmail} once the browser login opens.</Trans>
</p>
) : null}
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={ModalActionCreators.pop} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button variant="primary" onClick={handleOpenBrowser} submitting={isSubmitting}>
<ArrowSquareOutIcon size={16} weight="bold" />
<Trans>Open browser</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
},
);
export function showBrowserLoginHandoffModal(
onSuccess: (payload: LoginSuccessPayload) => Promise<void>,
targetWebAppUrl?: string,
prefillEmail?: string,
): void {
ModalActionCreators.push(
modal(() => (
<BrowserLoginHandoffModal
onSuccess={async (payload) => {
await onSuccess(payload);
}}
targetWebAppUrl={targetWebAppUrl}
prefillEmail={prefillEmail}
/>
)),
);
}
export default BrowserLoginHandoffModal;