/* * 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 . */ 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; 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(null); const [code, setCode] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const inputRef = useRef(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) => { setInstanceUrl(e.target.value); setInstanceUrlError(null); }, []); const handleCodeChange = useCallback( (e: React.ChangeEvent) => { 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 ( Log in using your browser, then enter the code shown to add the account. {canSwitchInstanceUrl ? ( {instanceUrlHelper} ) : null } /> ) : null} {prefillEmail ? ( We will prefill {prefillEmail} once the browser login opens. ) : null} Cancel Open browser ); }, ); export function showBrowserLoginHandoffModal( onSuccess: (payload: LoginSuccessPayload) => Promise, targetWebAppUrl?: string, prefillEmail?: string, ): void { ModalActionCreators.push( modal(() => ( { await onSuccess(payload); }} targetWebAppUrl={targetWebAppUrl} prefillEmail={prefillEmail} /> )), ); } export default BrowserLoginHandoffModal;
Log in using your browser, then enter the code shown to add the account.
We will prefill {prefillEmail} once the browser login opens.