refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -17,14 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/layout/AuthLayout.module.css';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import {motion} from 'framer-motion';
import type React from 'react';
import {GuildSplashCardAlignment} from '~/Constants';
import styles from '~/components/layout/AuthLayout.module.css';
const getSplashAlignmentStyles = (
alignment: (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment],
) => {
const getSplashAlignmentStyles = (alignment: ValueOf<typeof GuildSplashCardAlignment>) => {
switch (alignment) {
case GuildSplashCardAlignment.LEFT:
return {transformOrigin: 'bottom left', objectPosition: 'left bottom'};
@@ -44,7 +44,7 @@ export interface AuthBackgroundProps {
patternImageUrl: string;
className?: string;
useFullCover?: boolean;
splashAlignment?: (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment];
splashAlignment?: ValueOf<typeof GuildSplashCardAlignment>;
}
export const AuthBackground: React.FC<AuthBackgroundProps> = ({
@@ -68,7 +68,7 @@ export const AuthBackground: React.FC<AuthBackgroundProps> = ({
<motion.div
initial={{opacity: 0}}
animate={{opacity: splashLoaded ? 1 : 0}}
transition={{duration: 0.5, ease: 'easeInOut'}}
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.5, ease: 'easeInOut'}}
style={{position: 'absolute', inset: 0}}
>
<img
@@ -95,7 +95,7 @@ export const AuthBackground: React.FC<AuthBackgroundProps> = ({
className={styles.splashImage}
initial={{opacity: 0}}
animate={{opacity: splashLoaded ? 1 : 0}}
transition={{duration: 0.5, ease: 'easeInOut'}}
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.5, ease: 'easeInOut'}}
style={{
width: splashDimensions.width,
height: splashDimensions.height,

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/auth/AuthPageStyles.module.css';
import {AuthRouterLink} from '@app/components/auth/AuthRouterLink';
import {Trans} from '@lingui/react/macro';
import styles from './AuthPageStyles.module.css';
import {AuthRouterLink} from './AuthRouterLink';
interface AuthBottomLinkProps {
variant: 'login' | 'register';

View File

@@ -17,14 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/auth/AuthCardContainer.module.css';
import authLayoutStyles from '@app/components/layout/AuthLayout.module.css';
import FluxerLogo from '@app/images/fluxer-logo-color.svg?react';
import FluxerWordmark from '@app/images/fluxer-wordmark.svg?react';
import clsx from 'clsx';
import type {ReactNode} from 'react';
import authLayoutStyles from '~/components/layout/AuthLayout.module.css';
import FluxerLogo from '~/images/fluxer-logo-color.svg?react';
import FluxerWordmark from '~/images/fluxer-wordmark.svg?react';
import styles from './AuthCardContainer.module.css';
export interface AuthCardContainerProps {
interface AuthCardContainerProps {
showLogoSide?: boolean;
children: ReactNode;
isInert?: boolean;

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/auth/AuthPageStyles.module.css';
import type {Icon} from '@phosphor-icons/react';
import {QuestionIcon} from '@phosphor-icons/react';
import styles from './AuthPageStyles.module.css';
interface AuthErrorStateProps {
icon?: Icon;

View File

@@ -17,10 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Spinner} from '~/components/uikit/Spinner';
import styles from './AuthPageStyles.module.css';
import styles from '@app/components/auth/AuthPageStyles.module.css';
import {Spinner} from '@app/components/uikit/Spinner';
import type {JSX} from 'react';
export function AuthLoadingState() {
export function AuthLoadingState(): JSX.Element {
return (
<div className={styles.loadingContainer}>
<Spinner />

View File

@@ -17,26 +17,29 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
import {AccountSelector} from '@app/components/accounts/AccountSelector';
import {AuthRouterLink} from '@app/components/auth/AuthRouterLink';
import AuthLoginEmailPasswordForm from '@app/components/auth/auth_login_core/AuthLoginEmailPasswordForm';
import AuthLoginPasskeyActions, {AuthLoginDivider} from '@app/components/auth/auth_login_core/AuthLoginPasskeyActions';
import {useDesktopHandoffFlow} from '@app/components/auth/auth_login_core/useDesktopHandoffFlow';
import DesktopHandoffAccountSelector from '@app/components/auth/DesktopHandoffAccountSelector';
import {HandoffCodeDisplay} from '@app/components/auth/HandoffCodeDisplay';
import IpAuthorizationScreen from '@app/components/auth/IpAuthorizationScreen';
import styles from '@app/components/pages/LoginPage.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {useLoginFormController} from '@app/hooks/useLoginFlow';
import {IS_DEV} from '@app/lib/Env';
import {type Account, SessionExpiredError} from '@app/lib/SessionManager';
import AccountManager from '@app/stores/AccountManager';
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
import {isDesktop} from '@app/utils/NativeUtils';
import * as RouterUtils from '@app/utils/RouterUtils';
import {type IpAuthorizationChallenge, type LoginSuccessPayload, startSsoLogin} from '@app/viewmodels/auth/AuthFlow';
import {Trans, useLingui} from '@lingui/react/macro';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import {cloneElement, type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
import {AccountSelector} from '~/components/accounts/AccountSelector';
import AuthLoginEmailPasswordForm from '~/components/auth/AuthLoginCore/AuthLoginEmailPasswordForm';
import AuthLoginPasskeyActions, {AuthLoginDivider} from '~/components/auth/AuthLoginCore/AuthLoginPasskeyActions';
import {useDesktopHandoffFlow} from '~/components/auth/AuthLoginCore/useDesktopHandoffFlow';
import {AuthRouterLink} from '~/components/auth/AuthRouterLink';
import DesktopHandoffAccountSelector from '~/components/auth/DesktopHandoffAccountSelector';
import {HandoffCodeDisplay} from '~/components/auth/HandoffCodeDisplay';
import IpAuthorizationScreen from '~/components/auth/IpAuthorizationScreen';
import styles from '~/components/pages/LoginPage.module.css';
import {type IpAuthorizationChallenge, type LoginSuccessPayload, useLoginFormController} from '~/hooks/useLoginFlow';
import {IS_DEV} from '~/lib/env';
import {SessionExpiredError} from '~/lib/SessionManager';
import AccountManager, {type AccountSummary} from '~/stores/AccountManager';
import {isDesktop} from '~/utils/NativeUtils';
import * as RouterUtils from '~/utils/RouterUtils';
interface AuthLoginLayoutProps {
redirectPath?: string;
@@ -51,7 +54,7 @@ interface AuthLoginLayoutProps {
initialEmail?: string;
}
const AuthLoginLayout = observer(function AuthLoginLayout({
export const AuthLoginLayout = observer(function AuthLoginLayout({
redirectPath,
inviteCode,
desktopHandoff = false,
@@ -67,6 +70,10 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
const currentUserId = AccountManager.currentUserId;
const accounts = AccountManager.orderedAccounts;
const hasStoredAccounts = accounts.length > 0;
const ssoConfig = RuntimeConfigStore.sso;
const isSsoEnforced = Boolean(ssoConfig?.enforced);
const ssoDisplayName = ssoConfig?.display_name ?? 'Single Sign-On';
const [isStartingSso, setIsStartingSso] = useState(false);
const handoffAccounts =
desktopHandoff && excludeCurrentUser ? accounts.filter((a) => a.userId !== currentUserId) : accounts;
@@ -84,7 +91,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
const [switchError, setSwitchError] = useState<string | null>(null);
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
const showLoginFormForAccount = useCallback((account: AccountSummary, message?: string | null) => {
const showLoginFormForAccount = useCallback((account: Account, message?: string | null) => {
setShowAccountSelector(false);
setSwitchError(message ?? null);
setPrefillEmail(account.userData?.email ?? null);
@@ -143,7 +150,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
}, [form, prefillEmail]);
const handleSelectExistingAccount = useCallback(
async (account: AccountSummary) => {
async (account: Account) => {
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
const expiredMessage = t`Session expired for ${identifier}. Please log in again.`;
@@ -177,6 +184,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
setPrefillEmail(null);
}, []);
const handleStartSso = useCallback(async () => {
if (!ssoConfig?.enabled) return;
try {
setIsStartingSso(true);
const {authorizationUrl} = await startSsoLogin({
redirectTo: redirectPath,
});
window.location.assign(authorizationUrl);
} catch (error) {
setSwitchError(error instanceof Error ? error.message : t`Failed to start SSO`);
} finally {
setIsStartingSso(false);
}
}, [ssoConfig?.enabled, redirectPath, t]);
const styledRegisterLink = useMemo(() => {
const {className: linkClassName} = registerLink.props as {className?: string};
return cloneElement(registerLink, {
@@ -193,6 +215,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
);
}
if (isSsoEnforced) {
return (
<div className={styles.loginContainer}>
<h1 className={styles.title}>{ssoDisplayName}</h1>
<p className={styles.ssoSubtitle}>
<Trans>Sign in with your organization's single sign-on provider.</Trans>
</p>
<Button fitContainer onClick={handleStartSso} submitting={isStartingSso} type="button">
<Trans>Continue with SSO</Trans>
</Button>
{switchError && <div className={styles.loginNotice}>{switchError}</div>}
</div>
);
}
if (showAccountSelector && hasStoredAccounts && !desktopHandoff) {
return (
<AccountSelector
@@ -237,6 +274,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
{!showAccountSelector && switchError ? <div className={styles.loginNotice}>{switchError}</div> : null}
{ssoConfig?.enabled ? (
<div className={styles.ssoBlock}>
<Button fitContainer onClick={handleStartSso} submitting={isStartingSso} type="button">
<Trans>Continue with SSO</Trans>
</Button>
<div className={styles.ssoSubtitle}>
{ssoConfig.enforced ? (
<Trans>SSO is required to access this workspace.</Trans>
) : (
<Trans>Prefer using SSO? Continue with {ssoDisplayName}.</Trans>
)}
</div>
</div>
) : null}
<AuthLoginEmailPasswordForm
form={form}
isLoading={isLoading}
@@ -268,7 +320,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
onPasskeyLogin={handlePasskeyLogin}
showBrowserOption={showBrowserPasskey}
onBrowserLogin={handlePasskeyBrowserLogin}
browserLabel={<Trans>Log in via browser or custom instance</Trans>}
browserLabel={<Trans>Log in via browser</Trans>}
/>
<div className={styles.footer}>
@@ -282,5 +334,3 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
</>
);
});
export {AuthLoginLayout};

View File

@@ -17,18 +17,24 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
import styles from '@app/components/auth/AuthPageStyles.module.css';
import {DateOfBirthField} from '@app/components/auth/DateOfBirthField';
import FormField from '@app/components/auth/FormField';
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '@app/components/auth/SubmitTooltip';
import {ExternalLink} from '@app/components/common/ExternalLink';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import {
type AuthRegisterFormDraft,
EMPTY_AUTH_REGISTER_FORM_DRAFT,
useAuthRegisterDraftContext,
} from '@app/contexts/AuthRegisterDraftContext';
import {useAuthForm} from '@app/hooks/useAuthForm';
import {useLocation} from '@app/lib/router/React';
import {Routes} from '@app/Routes';
import {Trans, useLingui} from '@lingui/react/macro';
import {useId, useMemo, useState} from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
import {DateOfBirthField} from '~/components/auth/DateOfBirthField';
import FormField from '~/components/auth/FormField';
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '~/components/auth/SubmitTooltip';
import {ExternalLink} from '~/components/common/ExternalLink';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {useAuthForm} from '~/hooks/useAuthForm';
import {Routes} from '~/Routes';
import styles from './AuthPageStyles.module.css';
import {useCallback, useId, useMemo, useRef, useState} from 'react';
interface AuthMinimalRegisterFormCoreProps {
submitLabel: React.ReactNode;
@@ -46,17 +52,81 @@ export function AuthMinimalRegisterFormCore({
extraContent,
}: AuthMinimalRegisterFormCoreProps) {
const {t} = useLingui();
const location = useLocation();
const draftKey = `register:${location.pathname}${location.search}`;
const {getRegisterFormDraft, setRegisterFormDraft, clearRegisterFormDraft} = useAuthRegisterDraftContext();
const globalNameId = useId();
const [selectedMonth, setSelectedMonth] = useState('');
const [selectedDay, setSelectedDay] = useState('');
const [selectedYear, setSelectedYear] = useState('');
const [consent, setConsent] = useState(false);
const initialDraft = useMemo<AuthRegisterFormDraft>(() => {
const persistedDraft = getRegisterFormDraft(draftKey);
if (!persistedDraft) {
return EMPTY_AUTH_REGISTER_FORM_DRAFT;
}
return {
...persistedDraft,
formValues: {...persistedDraft.formValues},
};
}, [draftKey, getRegisterFormDraft]);
const draftRef = useRef<AuthRegisterFormDraft>({
...initialDraft,
formValues: {...initialDraft.formValues},
});
const [selectedMonth, setSelectedMonthState] = useState(initialDraft.selectedMonth);
const [selectedDay, setSelectedDayState] = useState(initialDraft.selectedDay);
const [selectedYear, setSelectedYearState] = useState(initialDraft.selectedYear);
const [consent, setConsentState] = useState(initialDraft.consent);
const initialValues: Record<string, string> = {
global_name: '',
global_name: initialDraft.formValues.global_name ?? '',
};
const persistDraft = useCallback(
(partialDraft: Partial<AuthRegisterFormDraft>) => {
const currentDraft = draftRef.current;
const nextDraft: AuthRegisterFormDraft = {
...currentDraft,
...partialDraft,
formValues: partialDraft.formValues ? {...partialDraft.formValues} : currentDraft.formValues,
};
draftRef.current = nextDraft;
setRegisterFormDraft(draftKey, nextDraft);
},
[draftKey, setRegisterFormDraft],
);
const handleMonthChange = useCallback(
(month: string) => {
setSelectedMonthState(month);
persistDraft({selectedMonth: month});
},
[persistDraft],
);
const handleDayChange = useCallback(
(day: string) => {
setSelectedDayState(day);
persistDraft({selectedDay: day});
},
[persistDraft],
);
const handleYearChange = useCallback(
(year: string) => {
setSelectedYearState(year);
persistDraft({selectedYear: year});
},
[persistDraft],
);
const handleConsentChange = useCallback(
(nextConsent: boolean) => {
setConsentState(nextConsent);
persistDraft({consent: nextConsent});
},
[persistDraft],
);
const handleRegisterSubmit = async (values: Record<string, string>) => {
const dateOfBirth =
selectedYear && selectedMonth && selectedDay
@@ -65,7 +135,6 @@ export function AuthMinimalRegisterFormCore({
const response = await AuthenticationActionCreators.register({
global_name: values.global_name || undefined,
beta_code: '',
date_of_birth: dateOfBirth,
consent,
invite_code: inviteCode,
@@ -79,6 +148,7 @@ export function AuthMinimalRegisterFormCore({
userId: response.user_id,
});
}
clearRegisterFormDraft(draftKey);
};
const {form, isLoading, fieldErrors} = useAuthForm({
@@ -87,10 +157,22 @@ export function AuthMinimalRegisterFormCore({
redirectPath,
firstFieldName: 'global_name',
});
const setDraftedFormValue = useCallback(
(fieldName: string, value: string) => {
form.setValue(fieldName, value);
const nextFormValues = {
...draftRef.current.formValues,
[fieldName]: value,
};
persistDraft({formValues: nextFormValues});
},
[form, persistDraft],
);
const missingFields = useMemo(() => {
const missing: Array<MissingField> = [];
if (!selectedMonth || !selectedDay || !selectedYear) {
missing.push({key: 'date_of_birth', label: t`Date of birth`});
missing.push({key: 'date_of_birth', label: t`Date of Birth`});
}
return missing;
}, [selectedMonth, selectedDay, selectedYear]);
@@ -103,10 +185,10 @@ export function AuthMinimalRegisterFormCore({
id={globalNameId}
name="global_name"
type="text"
label={t`Display name (optional)`}
label={t`Display Name (Optional)`}
placeholder={t`What should people call you?`}
value={globalNameValue}
onChange={(value) => form.setValue('global_name', value)}
onChange={(value) => setDraftedFormValue('global_name', value)}
error={form.getError('global_name') || fieldErrors?.global_name}
/>
@@ -114,16 +196,16 @@ export function AuthMinimalRegisterFormCore({
selectedMonth={selectedMonth}
selectedDay={selectedDay}
selectedYear={selectedYear}
onMonthChange={setSelectedMonth}
onDayChange={setSelectedDay}
onYearChange={setSelectedYear}
onMonthChange={handleMonthChange}
onDayChange={handleDayChange}
onYearChange={handleYearChange}
error={fieldErrors?.date_of_birth}
/>
{extraContent}
<div className={styles.consentRow}>
<Checkbox checked={consent} onChange={setConsent}>
<Checkbox checked={consent} onChange={handleConsentChange}>
<span className={styles.consentLabel}>
<Trans>I agree to the</Trans>{' '}
<ExternalLink href={Routes.terms()} className={styles.policyLink}>

View File

@@ -17,11 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {SealCheckIcon} from '@phosphor-icons/react';
import styles from '@app/components/auth/AuthPageStyles.module.css';
import {GuildBadge} from '@app/components/guild/GuildBadge';
import type {ReactNode} from 'react';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import styles from './AuthPageStyles.module.css';
interface AuthPageHeaderStatProps {
value: string | number;
@@ -32,12 +30,11 @@ interface AuthPageHeaderProps {
icon: ReactNode;
title: string;
subtitle: string;
verified?: boolean;
features?: ReadonlyArray<string>;
stats?: Array<AuthPageHeaderStatProps>;
}
export function AuthPageHeader({icon, title, subtitle, verified, stats}: AuthPageHeaderProps) {
const {t} = useLingui();
export function AuthPageHeader({icon, title, subtitle, features, stats}: AuthPageHeaderProps) {
return (
<div className={styles.entityHeader}>
{icon}
@@ -45,11 +42,7 @@ export function AuthPageHeader({icon, title, subtitle, verified, stats}: AuthPag
<p className={styles.entityText}>{title}</p>
<div className={styles.entityTitleWrapper}>
<h2 className={styles.entityTitle}>{subtitle}</h2>
{verified && (
<Tooltip text={t`Verified Community`} position="top">
<SealCheckIcon className={styles.verifiedIcon} />
</Tooltip>
)}
{features && <GuildBadge features={features} />}
</div>
{stats && stats.length > 0 && (
<div className={styles.entityStats}>

View File

@@ -298,6 +298,28 @@
color: var(--text-tertiary);
}
.suggestionLink {
padding: 0;
background: none;
border: none;
font-size: 0.75rem;
color: var(--text-link);
cursor: pointer;
text-decoration: none;
transition: text-decoration 150ms ease;
}
.suggestionLink:hover {
text-decoration: underline;
}
.usernameError {
margin-top: 0.25rem;
display: block;
font-size: 0.75rem;
color: var(--status-danger);
}
.consentRow {
display: flex;
align-items: flex-start;
@@ -369,22 +391,6 @@
color: var(--text-primary);
}
.betaCodeHint {
margin-top: -0.75rem;
font-size: 0.75rem;
line-height: 1rem;
color: var(--text-tertiary);
}
.usernameValidation {
margin-bottom: 1rem;
border-radius: 0.375rem;
border-width: 1px;
border-color: var(--background-modifier-accent);
background-color: var(--background-secondary);
padding: 0.75rem;
}
.giftIconContainer {
display: flex;
height: 5rem;

View File

@@ -17,30 +17,33 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
import styles from '@app/components/auth/AuthPageStyles.module.css';
import {DateOfBirthField} from '@app/components/auth/DateOfBirthField';
import FormField from '@app/components/auth/FormField';
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '@app/components/auth/SubmitTooltip';
import {ExternalLink} from '@app/components/common/ExternalLink';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import {
type AuthRegisterFormDraft,
EMPTY_AUTH_REGISTER_FORM_DRAFT,
useAuthRegisterDraftContext,
} from '@app/contexts/AuthRegisterDraftContext';
import {useAuthForm} from '@app/hooks/useAuthForm';
import {useUsernameSuggestions} from '@app/hooks/useUsernameSuggestions';
import {useLocation} from '@app/lib/router/React';
import {Routes} from '@app/Routes';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import {Trans, useLingui} from '@lingui/react/macro';
import {AnimatePresence} from 'framer-motion';
import {useId, useMemo, useState} from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
import {DateOfBirthField} from '~/components/auth/DateOfBirthField';
import FormField from '~/components/auth/FormField';
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '~/components/auth/SubmitTooltip';
import {UsernameSuggestions} from '~/components/auth/UsernameSuggestions';
import {ExternalLink} from '~/components/common/ExternalLink';
import {UsernameValidationRules} from '~/components/form/UsernameValidationRules';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {useAuthForm} from '~/hooks/useAuthForm';
import {useUsernameSuggestions} from '~/hooks/useUsernameSuggestions';
import {MODE} from '~/lib/env';
import {Routes} from '~/Routes';
import styles from './AuthPageStyles.module.css';
import {AnimatePresence, motion} from 'framer-motion';
import {useCallback, useId, useMemo, useRef, useState} from 'react';
interface FieldConfig {
showEmail?: boolean;
showPassword?: boolean;
showPasswordConfirmation?: boolean;
showUsernameValidation?: boolean;
showBetaCodeHint?: boolean;
requireBetaCode?: boolean;
}
interface AuthRegisterFormCoreProps {
@@ -64,31 +67,102 @@ export function AuthRegisterFormCore({
const {
showEmail = false,
showPassword = false,
showPasswordConfirmation = false,
showUsernameValidation = false,
requireBetaCode = MODE !== 'development',
} = fields;
const location = useLocation();
const draftKey = `register:${location.pathname}${location.search}`;
const {getRegisterFormDraft, setRegisterFormDraft, clearRegisterFormDraft} = useAuthRegisterDraftContext();
const emailId = useId();
const globalNameId = useId();
const usernameId = useId();
const passwordId = useId();
const betaCodeId = useId();
const confirmPasswordId = useId();
const [selectedMonth, setSelectedMonth] = useState('');
const [selectedDay, setSelectedDay] = useState('');
const [selectedYear, setSelectedYear] = useState('');
const [consent, setConsent] = useState(false);
const [usernameFocused, setUsernameFocused] = useState(false);
const initialDraft = useMemo<AuthRegisterFormDraft>(() => {
const persistedDraft = getRegisterFormDraft(draftKey);
if (!persistedDraft) {
return EMPTY_AUTH_REGISTER_FORM_DRAFT;
}
return {
...persistedDraft,
formValues: {...persistedDraft.formValues},
};
}, [draftKey, getRegisterFormDraft]);
const draftRef = useRef<AuthRegisterFormDraft>({
...initialDraft,
formValues: {...initialDraft.formValues},
});
const [selectedMonth, setSelectedMonthState] = useState(initialDraft.selectedMonth);
const [selectedDay, setSelectedDayState] = useState(initialDraft.selectedDay);
const [selectedYear, setSelectedYearState] = useState(initialDraft.selectedYear);
const [consent, setConsentState] = useState(initialDraft.consent);
const [_usernameFocused, setUsernameFocused] = useState(false);
const initialValues: Record<string, string> = {
global_name: '',
username: '',
betaCode: '',
global_name: initialDraft.formValues.global_name ?? '',
username: initialDraft.formValues.username ?? '',
};
if (showEmail) initialValues.email = '';
if (showPassword) initialValues.password = '';
if (showEmail) initialValues.email = initialDraft.formValues.email ?? '';
if (showPassword) initialValues.password = initialDraft.formValues.password ?? '';
if (showPassword && showPasswordConfirmation) {
initialValues.confirm_password = initialDraft.formValues.confirm_password ?? '';
}
const persistDraft = useCallback(
(partialDraft: Partial<AuthRegisterFormDraft>) => {
const currentDraft = draftRef.current;
const nextDraft: AuthRegisterFormDraft = {
...currentDraft,
...partialDraft,
formValues: partialDraft.formValues ? {...partialDraft.formValues} : currentDraft.formValues,
};
draftRef.current = nextDraft;
setRegisterFormDraft(draftKey, nextDraft);
},
[draftKey, setRegisterFormDraft],
);
const handleMonthChange = useCallback(
(month: string) => {
setSelectedMonthState(month);
persistDraft({selectedMonth: month});
},
[persistDraft],
);
const handleDayChange = useCallback(
(day: string) => {
setSelectedDayState(day);
persistDraft({selectedDay: day});
},
[persistDraft],
);
const handleYearChange = useCallback(
(year: string) => {
setSelectedYearState(year);
persistDraft({selectedYear: year});
},
[persistDraft],
);
const handleConsentChange = useCallback(
(nextConsent: boolean) => {
setConsentState(nextConsent);
persistDraft({consent: nextConsent});
},
[persistDraft],
);
const handleRegisterSubmit = async (values: Record<string, string>) => {
if (showPasswordConfirmation && showPassword && values.password !== values.confirm_password) {
form.setError('confirm_password', t`Passwords do not match`);
return;
}
const dateOfBirth =
selectedYear && selectedMonth && selectedDay
? `${selectedYear}-${selectedMonth.padStart(2, '0')}-${selectedDay.padStart(2, '0')}`
@@ -99,7 +173,6 @@ export function AuthRegisterFormCore({
username: values.username || undefined,
email: showEmail ? values.email : undefined,
password: showPassword ? values.password : undefined,
beta_code: values.betaCode || '',
date_of_birth: dateOfBirth,
consent,
invite_code: inviteCode,
@@ -113,6 +186,7 @@ export function AuthRegisterFormCore({
userId: response.user_id,
});
}
clearRegisterFormDraft(draftKey);
};
const {form, isLoading, fieldErrors} = useAuthForm({
@@ -122,6 +196,18 @@ export function AuthRegisterFormCore({
firstFieldName: showEmail ? 'email' : 'global_name',
});
const setDraftedFormValue = useCallback(
(fieldName: string, value: string) => {
form.setValue(fieldName, value);
const nextFormValues = {
...draftRef.current.formValues,
[fieldName]: value,
};
persistDraft({formValues: nextFormValues});
},
[form, persistDraft],
);
const {suggestions} = useUsernameSuggestions({
globalName: form.getValue('global_name'),
username: form.getValue('username'),
@@ -135,17 +221,36 @@ export function AuthRegisterFormCore({
if (showPassword && !form.getValue('password')) {
missing.push({key: 'password', label: t`Password`});
}
if (!selectedMonth || !selectedDay || !selectedYear) {
missing.push({key: 'date_of_birth', label: t`Date of birth`});
if (showPassword && showPasswordConfirmation && !form.getValue('confirm_password')) {
missing.push({key: 'confirm_password', label: t`Confirm Password`});
}
if (requireBetaCode && !form.getValue('betaCode')) {
missing.push({key: 'betaCode', label: t`Beta code`});
if (!selectedMonth || !selectedDay || !selectedYear) {
missing.push({key: 'date_of_birth', label: t`Date of Birth`});
}
return missing;
}, [form, selectedMonth, selectedDay, selectedYear, showEmail, showPassword, requireBetaCode]);
}, [form, selectedMonth, selectedDay, selectedYear, showEmail, showPassword, showPasswordConfirmation]);
type HelperTextState = {type: 'error'; message: string} | {type: 'suggestion'; username: string} | {type: 'hint'};
const usernameValue = form.getValue('username');
const showValidationRules = showUsernameValidation && usernameValue && (usernameFocused || usernameValue.length > 0);
const helperTextState = useMemo<HelperTextState>(() => {
const trimmed = usernameValue?.trim() || '';
if (showUsernameValidation && trimmed.length > 0) {
if (trimmed.length > 32) {
return {type: 'error', message: t`Username must be 32 characters or less`};
}
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
return {type: 'error', message: t`Only letters, numbers, and underscores`};
}
}
if (trimmed.length === 0 && suggestions.length === 1) {
return {type: 'suggestion', username: suggestions[0]};
}
return {type: 'hint'};
}, [usernameValue, suggestions, showUsernameValidation, t]);
return (
<form className={styles.form} onSubmit={form.handleSubmit}>
@@ -158,7 +263,7 @@ export function AuthRegisterFormCore({
required
label={t`Email`}
value={form.getValue('email')}
onChange={(value) => form.setValue('email', value)}
onChange={(value) => setDraftedFormValue('email', value)}
error={form.getError('email') || fieldErrors?.email}
/>
)}
@@ -167,10 +272,10 @@ export function AuthRegisterFormCore({
id={globalNameId}
name="global_name"
type="text"
label={t`Display name (optional)`}
label={t`Display Name (Optional)`}
placeholder={t`What should people call you?`}
value={form.getValue('global_name')}
onChange={(value) => form.setValue('global_name', value)}
onChange={(value) => setDraftedFormValue('global_name', value)}
error={form.getError('global_name') || fieldErrors?.global_name}
/>
@@ -180,32 +285,60 @@ export function AuthRegisterFormCore({
name="username"
type="text"
autoComplete="username"
label={t`Username (optional)`}
label={t`Username (Optional)`}
placeholder={t`Leave blank for a random username`}
value={usernameValue}
onChange={(value) => form.setValue('username', value)}
onChange={(value) => setDraftedFormValue('username', value)}
onFocus={() => setUsernameFocused(true)}
onBlur={() => setUsernameFocused(false)}
error={form.getError('username') || fieldErrors?.username}
/>
<span className={styles.usernameHint}>
<Trans>A 4-digit tag will be added automatically to ensure uniqueness</Trans>
</span>
</div>
{showUsernameValidation && (
<AnimatePresence>
{showValidationRules && (
<div className={styles.usernameValidation}>
<UsernameValidationRules username={usernameValue} />
</div>
<AnimatePresence mode="wait" initial={false}>
{helperTextState.type === 'error' && (
<motion.span
key="error"
className={styles.usernameError}
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
animate={{opacity: 1, y: 0}}
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
>
{helperTextState.message}
</motion.span>
)}
{helperTextState.type === 'suggestion' && (
<motion.span
key="suggestion"
className={styles.usernameHint}
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
animate={{opacity: 1, y: 0}}
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
>
<Trans>How about:</Trans>{' '}
<button
type="button"
className={styles.suggestionLink}
onClick={() => setDraftedFormValue('username', helperTextState.username)}
>
{helperTextState.username}
</button>
</motion.span>
)}
{helperTextState.type === 'hint' && (
<motion.span
key="hint"
className={styles.usernameHint}
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
animate={{opacity: 1, y: 0}}
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
>
<Trans>A 4-digit tag will be added automatically to ensure uniqueness</Trans>
</motion.span>
)}
</AnimatePresence>
)}
{!usernameValue && (
<UsernameSuggestions suggestions={suggestions} onSelect={(username) => form.setValue('username', username)} />
)}
</div>
{showPassword && (
<FormField
@@ -216,31 +349,21 @@ export function AuthRegisterFormCore({
required
label={t`Password`}
value={form.getValue('password')}
onChange={(value) => form.setValue('password', value)}
onChange={(value) => setDraftedFormValue('password', value)}
error={form.getError('password') || fieldErrors?.password}
/>
)}
{requireBetaCode ? (
{showPassword && showPasswordConfirmation && (
<FormField
id={betaCodeId}
name="betaCode"
type="text"
id={confirmPasswordId}
name="confirm_password"
type="password"
autoComplete="new-password"
required
label={t`Beta code`}
value={form.getValue('betaCode')}
onChange={(value) => form.setValue('betaCode', value)}
error={form.getError('betaCode') || fieldErrors?.beta_code}
/>
) : (
<FormField
id={betaCodeId}
name="betaCode"
type="text"
label={t`Beta code (optional)`}
value={form.getValue('betaCode')}
onChange={(value) => form.setValue('betaCode', value)}
error={form.getError('betaCode') || fieldErrors?.beta_code}
label={t`Confirm Password`}
value={form.getValue('confirm_password')}
onChange={(value) => setDraftedFormValue('confirm_password', value)}
error={form.getError('confirm_password')}
/>
)}
@@ -248,16 +371,16 @@ export function AuthRegisterFormCore({
selectedMonth={selectedMonth}
selectedDay={selectedDay}
selectedYear={selectedYear}
onMonthChange={setSelectedMonth}
onDayChange={setSelectedDay}
onYearChange={setSelectedYear}
onMonthChange={handleMonthChange}
onDayChange={handleDayChange}
onYearChange={handleYearChange}
error={fieldErrors?.date_of_birth}
/>
{extraContent}
<div className={styles.consentRow}>
<Checkbox checked={consent} onChange={setConsent}>
<Checkbox checked={consent} onChange={handleConsentChange}>
<span className={styles.consentLabel}>
<Trans>I agree to the</Trans>{' '}
<ExternalLink href={Routes.terms()} className={styles.policyLink}>

View File

@@ -17,16 +17,16 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Link as RouterLink} from '@app/lib/router/React';
import type {ReactNode} from 'react';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Link as RouterLink} from '~/lib/router';
interface AuthRouterLinkProps {
ringOffset?: number;
children?: ReactNode;
className?: string;
to: string;
search?: Record<string, string | undefined>;
search?: Record<string, string>;
}
export function AuthRouterLink({ringOffset = -2, children, className, to, search}: AuthRouterLinkProps) {

View File

@@ -17,24 +17,21 @@
* 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, CheckCircleIcon} from '@phosphor-icons/react';
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {Input} from '~/components/form/Input';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {IS_DEV} from '~/lib/env';
import HttpClient from '~/lib/HttpClient';
import RuntimeConfigStore, {describeApiEndpoint, type InstanceDiscoveryResponse} from '~/stores/RuntimeConfigStore';
import {isDesktop, openExternalUrl} from '~/utils/NativeUtils';
import styles from './BrowserLoginHandoffModal.module.css';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
interface LoginSuccessPayload {
token: string;
@@ -47,35 +44,9 @@ interface BrowserLoginHandoffModalProps {
prefillEmail?: string;
}
interface ValidatedInstance {
apiEndpoint: string;
webAppUrl: string;
}
type ModalView = 'main' | 'instance';
const CODE_LENGTH = 8;
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{8}$/;
const normalizeEndpoint = (input: string): string => {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('API endpoint is required');
}
let candidate = trimmed;
if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(candidate)) {
candidate = `https://${candidate}`;
}
const url = new URL(candidate);
if (url.pathname === '' || url.pathname === '/') {
url.pathname = '/api';
}
url.pathname = url.pathname.replace(/\/+$/, '');
return url.toString();
};
const formatCodeForDisplay = (raw: string): string => {
const cleaned = raw
.replace(/[^A-Za-z0-9]/g, '')
@@ -95,24 +66,46 @@ const extractRawCode = (formatted: string): string => {
.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 [view, setView] = React.useState<ModalView>('main');
const [code, setCode] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const electronApi = getElectronAPI();
const switchInstanceUrl = electronApi?.switchInstanceUrl;
const canSwitchInstanceUrl = typeof switchInstanceUrl === 'function';
const [customInstance, setCustomInstance] = React.useState('');
const [instanceValidating, setInstanceValidating] = React.useState(false);
const [instanceError, setInstanceError] = React.useState<string | null>(null);
const [validatedInstance, setValidatedInstance] = React.useState<ValidatedInstance | null>(null);
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
const [instanceUrl, setInstanceUrl] = useState(() => targetWebAppUrl ?? currentWebAppUrl);
const [instanceUrlError, setInstanceUrlError] = useState<string | null>(null);
const showInstanceOption = IS_DEV || isDesktop();
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 handleSubmit = React.useCallback(
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;
@@ -120,15 +113,42 @@ const BrowserLoginHandoffModal = observer(
setIsSubmitting(true);
setError(null);
setInstanceUrlError(null);
try {
const customApiEndpoint = validatedInstance?.apiEndpoint;
const result = await AuthenticationActionCreators.pollDesktopHandoffStatus(rawCode, customApiEndpoint);
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) {
if (customApiEndpoint) {
await RuntimeConfigStore.connectToEndpoint(customApiEndpoint);
}
await onSuccess({token: result.token, userId: result.user_id});
ModalActionCreators.pop();
return;
@@ -143,17 +163,25 @@ const BrowserLoginHandoffModal = observer(
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
setIsSubmitting(false);
if (!switchingInstanceRef.current) {
setIsSubmitting(false);
}
}
},
[i18n, onSuccess, validatedInstance],
[canSwitchInstanceUrl, i18n, instanceUrl, onSuccess, switchInstanceUrl],
);
const handleCodeChange = React.useCallback(
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);
@@ -162,126 +190,61 @@ const BrowserLoginHandoffModal = observer(
[handleSubmit],
);
const handleOpenBrowser = React.useCallback(async () => {
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
const baseUrl = validatedInstance?.webAppUrl || targetWebAppUrl || currentWebAppUrl;
const handleOpenBrowser = useCallback(async () => {
const fallbackUrl = targetWebAppUrl || currentWebAppUrl;
let baseUrl = fallbackUrl;
const params = new URLSearchParams({desktop_handoff: '1'});
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) {
params.set('email', prefillEmail);
loginUrl.searchParams.set('email', prefillEmail);
}
const url = `${baseUrl}/login?${params.toString()}`;
await openExternalUrl(url);
}, [prefillEmail, targetWebAppUrl, validatedInstance]);
await openExternalUrl(loginUrl.toString());
}, [canSwitchInstanceUrl, currentWebAppUrl, i18n, instanceUrl, prefillEmail, targetWebAppUrl]);
const handleShowInstanceView = React.useCallback(() => {
setView('instance');
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleBackToMain = React.useCallback(() => {
setView('main');
setInstanceError(null);
}, []);
const handleSaveInstance = React.useCallback(async () => {
if (!customInstance.trim()) {
setInstanceError(i18n._(msg`Please enter an API endpoint.`));
return;
}
setInstanceValidating(true);
setInstanceError(null);
try {
const apiEndpoint = normalizeEndpoint(customInstance);
const instanceUrl = `${apiEndpoint}/instance`;
const response = await HttpClient.get<InstanceDiscoveryResponse>({url: instanceUrl});
if (!response.ok) {
const status = String(response.status);
throw new Error(i18n._(msg`Failed to reach instance (${status})`));
}
const instance = response.body;
if (!instance.endpoints?.webapp) {
throw new Error(i18n._(msg`Invalid instance response: missing webapp URL.`));
}
const webAppUrl = instance.endpoints.webapp.replace(/\/$/, '');
setValidatedInstance({apiEndpoint, webAppUrl});
setView('main');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setInstanceError(message);
} finally {
setInstanceValidating(false);
}
}, [customInstance, i18n]);
const handleClearInstance = React.useCallback(() => {
setValidatedInstance(null);
setCustomInstance('');
setInstanceError(null);
}, []);
React.useEffect(() => {
if (view === 'main') {
inputRef.current?.focus();
}
}, [view]);
if (view === 'instance') {
return (
<Modal.Root size="small" centered onClose={ModalActionCreators.pop}>
<Modal.Header title={i18n._(msg`Custom instance`)} />
<Modal.Content className={styles.content}>
<Input
label={i18n._(msg`API Endpoint`)}
type="url"
placeholder="https://api.example.com"
value={customInstance}
onChange={(e) => {
setCustomInstance(e.target.value);
setInstanceError(null);
}}
error={instanceError ?? undefined}
disabled={instanceValidating}
footer={
!instanceError ? (
<p className={styles.inputHelper}>
<Trans>Enter the API endpoint of the Fluxer instance you want to connect to.</Trans>
</p>
) : null
}
autoFocus
/>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={handleBackToMain} disabled={instanceValidating}>
<Trans>Back</Trans>
</Button>
<Button
variant="primary"
onClick={handleSaveInstance}
disabled={instanceValidating || !customInstance.trim()}
>
{instanceValidating ? <Trans>Checking...</Trans> : <Trans>Save</Trans>}
</Button>
</Modal.Footer>
</Modal.Root>
);
}
return (
<Modal.Root size="small" centered onClose={ModalActionCreators.pop}>
<Modal.Header title={i18n._(msg`Add account`)} />
<Modal.Content className={styles.content}>
<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}
@@ -294,22 +257,6 @@ const BrowserLoginHandoffModal = observer(
/>
</div>
{validatedInstance ? (
<div className={styles.instanceBadge}>
<CheckCircleIcon size={14} weight="fill" className={styles.instanceBadgeIcon} />
<span className={styles.instanceBadgeText}>
<Trans>Using {describeApiEndpoint(validatedInstance.apiEndpoint)}</Trans>
</span>
<button type="button" className={styles.instanceBadgeClear} onClick={handleClearInstance}>
<Trans>Clear</Trans>
</button>
</div>
) : showInstanceOption ? (
<button type="button" className={styles.instanceLink} onClick={handleShowInstanceView}>
<Trans>I want to use a custom Fluxer instance</Trans>
</button>
) : null}
{prefillEmail ? (
<p className={styles.prefillHint}>
<Trans>We will prefill {prefillEmail} once the browser login opens.</Trans>

View File

@@ -112,11 +112,6 @@
background-color: var(--background-modifier-hover);
}
.nativeDateInput:focus {
outline: none;
border-color: var(--background-modifier-accent-focus);
}
.nativeDateInput[aria-invalid='true'] {
border-color: var(--status-danger);
}

View File

@@ -17,43 +17,20 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/auth/DateOfBirthField.module.css';
import {Select} from '@app/components/form/Select';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/lib/PasswordManagerAutocomplete';
import {getCurrentLocale} from '@app/utils/LocaleUtils';
import {isMobileExperienceEnabled} from '@app/utils/MobileExperience';
import {getDateFieldOrder, getMonthNames} from '@fluxer/date_utils/src/DateIntrospection';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useMemo} from 'react';
import {Select} from '~/components/form/Select';
import {getCurrentLocale} from '~/utils/LocaleUtils';
import styles from './DateOfBirthField.module.css';
type DateFieldType = 'month' | 'day' | 'year';
function isMobileWebBrowser(): boolean {
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
function getDateFieldOrder(locale: string): Array<DateFieldType> {
const formatter = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = formatter.formatToParts(new Date(2000, 0, 1));
const order: Array<DateFieldType> = [];
for (const part of parts) {
if (part.type === 'month' && !order.includes('month')) {
order.push('month');
} else if (part.type === 'day' && !order.includes('day')) {
order.push('day');
} else if (part.type === 'year' && !order.includes('year')) {
order.push('year');
}
}
return order;
}
interface DateOfBirthFieldProps {
selectedMonth: string;
selectedDay: string;
@@ -122,16 +99,19 @@ function NativeDatePicker({
</legend>
</div>
<div className={styles.inputsContainer}>
<input
type="date"
className={styles.nativeDateInput}
value={dateValue}
onChange={handleDateChange}
min={minDate}
max={maxDate}
placeholder={dateOfBirthPlaceholder}
aria-invalid={!!error || undefined}
/>
<FocusRing offset={-2}>
<input
type="date"
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
className={styles.nativeDateInput}
value={dateValue}
onChange={handleDateChange}
min={minDate}
max={maxDate}
placeholder={dateOfBirthPlaceholder}
aria-invalid={!!error || undefined}
/>
</FocusRing>
{error && <span className={styles.errorText}>{error}</span>}
</div>
</fieldset>
@@ -159,12 +139,11 @@ export const DateOfBirthField = observer(function DateOfBirthField({
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const monthNames = getMonthNames(locale);
const allMonths = Array.from({length: 12}, (_, index) => {
const monthDate = new Date(2000, index, 1);
const monthName = new Intl.DateTimeFormat(locale, {month: 'long'}).format(monthDate);
return {
value: String(index + 1),
label: monthName,
label: monthNames[index],
};
});
@@ -195,7 +174,7 @@ export const DateOfBirthField = observer(function DateOfBirthField({
};
}, [selectedYear, selectedMonth, locale]);
if (isMobileWebBrowser()) {
if (isMobileExperienceEnabled()) {
return (
<NativeDatePicker
selectedMonth={selectedMonth}

View File

@@ -17,15 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/auth/DesktopDeepLinkPrompt.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {Platform} from '@app/lib/Platform';
import {Routes} from '@app/Routes';
import {buildAppProtocolUrl} from '@app/utils/AppProtocol';
import {checkDesktopAvailable, navigateInDesktop} from '@app/utils/DesktopRpcClient';
import {isDesktop, openExternalUrl} from '@app/utils/NativeUtils';
import {Trans} from '@lingui/react/macro';
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
import type React from 'react';
import {useEffect, useState} from 'react';
import {Routes} from '~/Routes';
import {checkDesktopAvailable, navigateInDesktop} from '~/utils/DesktopRpcClient';
import {isDesktop} from '~/utils/NativeUtils';
import {Button} from '../uikit/Button/Button';
import styles from './DesktopDeepLinkPrompt.module.css';
interface DesktopDeepLinkPromptProps {
code: string;
@@ -37,9 +39,12 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
const [isLoading, setIsLoading] = useState(false);
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
const isMobileBrowser = Platform.isMobileBrowser;
const useProtocolLaunch = kind === 'invite';
const shouldProbeDesktopAvailability = !useProtocolLaunch;
useEffect(() => {
if (isDesktop()) return;
if (isDesktop() || !shouldProbeDesktopAvailability) return;
let cancelled = false;
checkDesktopAvailable().then(({available}) => {
@@ -50,11 +55,11 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
return () => {
cancelled = true;
};
}, []);
}, [shouldProbeDesktopAvailability]);
if (isDesktop()) return null;
if (isDesktop() || isMobileBrowser) return null;
if (desktopAvailable !== true) return null;
if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null;
const getPath = (): string => {
switch (kind) {
@@ -73,6 +78,17 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
setIsLoading(true);
setError(null);
if (useProtocolLaunch) {
try {
await openExternalUrl(buildAppProtocolUrl(path));
} catch {
setError('Failed to open in desktop app');
} finally {
setIsLoading(false);
}
return;
}
const result = await navigateInDesktop(path);
setIsLoading(false);

View File

@@ -17,14 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
import {AccountSelector} from '@app/components/accounts/AccountSelector';
import {HandoffCodeDisplay} from '@app/components/auth/HandoffCodeDisplay';
import {type Account, SessionExpiredError} from '@app/lib/SessionManager';
import AccountManager from '@app/stores/AccountManager';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useState} from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
import {AccountSelector} from '~/components/accounts/AccountSelector';
import {HandoffCodeDisplay} from '~/components/auth/HandoffCodeDisplay';
import {SessionExpiredError} from '~/lib/SessionManager';
import AccountManager, {type AccountSummary} from '~/stores/AccountManager';
type HandoffState = 'selecting' | 'generating' | 'displaying' | 'error';
@@ -48,7 +48,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
const accounts = excludeCurrentUser ? allAccounts.filter((account) => account.userId !== currentUserId) : allAccounts;
const isGenerating = handoffState === 'generating';
const handleSelectAccount = useCallback(async (account: AccountSummary) => {
const handleSelectAccount = useCallback(async (account: Account) => {
setSelectedAccountId(account.userId);
setHandoffState('generating');
setHandoffError(null);
@@ -105,7 +105,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
return (
<AccountSelector
accounts={accounts}
title={<Trans>Choose an account</Trans>}
title={<Trans>Choose an Account</Trans>}
description={<Trans>Select the account you want to sign in with on the desktop app.</Trans>}
disabled={isGenerating}
showInstance

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Input} from '@app/components/form/Input';
import {observer} from 'mobx-react-lite';
import {Input} from '~/components/form/Input';
interface FormFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> {
name: string;

View File

@@ -17,11 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Gift} from '@app/actions/GiftActionCreators';
import styles from '@app/components/auth/AuthPageStyles.module.css';
import {getPremiumGiftDurationText} from '@app/utils/GiftUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import {GiftIcon} from '@phosphor-icons/react';
import type {Gift} from '~/actions/GiftActionCreators';
import {getPremiumGiftDurationText} from '~/utils/giftUtils';
import styles from './AuthPageStyles.module.css';
interface GiftHeaderProps {
gift: Gift;

View File

@@ -17,13 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as TextCopyActionCreators from '@app/actions/TextCopyActionCreators';
import styles from '@app/components/auth/HandoffCodeDisplay.module.css';
import {Button} from '@app/components/uikit/button/Button';
import i18n from '@app/I18n';
import {Trans} from '@lingui/react/macro';
import {CheckCircleIcon, ClipboardIcon} from '@phosphor-icons/react';
import {useCallback, useState} from 'react';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import {Button} from '~/components/uikit/Button/Button';
import i18n from '~/i18n';
import styles from './HandoffCodeDisplay.module.css';
interface HandoffCodeDisplayProps {
code: string | null;

View File

@@ -0,0 +1,191 @@
/*
* 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/>.
*/
.container {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.inputContainer {
position: relative;
width: 100%;
}
.inputActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.statusSpinner {
width: 18px;
height: 18px;
}
.statusSuccess {
color: var(--status-positive);
}
.statusError {
color: var(--status-danger);
}
.dropdownToggle {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
border-radius: 4px;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.dropdownToggle:hover:not(:disabled) {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.dropdownToggle:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.caretIcon {
transition: transform 0.2s ease;
}
.caretIconOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
margin-top: 4px;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.dropdownHeader {
padding: 8px 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--background-modifier-accent);
}
.dropdownList {
list-style: none;
margin: 0;
padding: 4px;
}
.dropdownItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
text-align: left;
color: var(--text-primary);
transition: background-color 0.15s ease;
}
.dropdownItem:hover {
background-color: var(--background-modifier-hover);
}
.instanceIcon {
flex-shrink: 0;
color: var(--text-tertiary);
}
.instanceDomain {
flex: 1;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.instanceName {
font-size: 0.75rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.removeButton {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
border-radius: 4px;
opacity: 0;
transition:
opacity 0.15s ease,
color 0.15s ease,
background-color 0.15s ease;
}
.dropdownItem:hover .removeButton {
opacity: 1;
}
.removeButton:hover {
color: var(--status-danger);
background-color: var(--background-modifier-hover);
}
.errorMessage {
padding: 8px 12px;
border-radius: 6px;
background-color: hsla(0, calc(100% * var(--saturation-factor, 1)), 50%, 0.1);
border: 1px solid hsla(0, calc(100% * var(--saturation-factor, 1)), 50%, 0.2);
font-size: 0.8125rem;
color: var(--status-danger);
}
.connectButton {
align-self: flex-start;
}

View File

@@ -0,0 +1,313 @@
/*
* 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 styles from '@app/components/auth/InstanceSelector.module.css';
import {Input} from '@app/components/form/Input';
import {Button} from '@app/components/uikit/button/Button';
import {Spinner} from '@app/components/uikit/Spinner';
import AppStorage from '@app/lib/AppStorage';
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
import {Trans, useLingui} from '@lingui/react/macro';
import {CaretDownIcon, CheckCircleIcon, GlobeIcon, TrashIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const RECENT_INSTANCES_KEY = 'federation_recent_instances';
const MAX_RECENT_INSTANCES = 5;
export type InstanceDiscoveryStatus = 'idle' | 'discovering' | 'success' | 'error';
export interface InstanceInfo {
domain: string;
name?: string;
lastUsed: number;
}
interface InstanceSelectorProps {
value: string;
onChange: (value: string) => void;
onInstanceDiscovered?: (domain: string) => void;
onDiscoveryStatusChange?: (status: InstanceDiscoveryStatus) => void;
disabled?: boolean;
className?: string;
}
function loadRecentInstances(): Array<InstanceInfo> {
const stored = AppStorage.getJSON<Array<InstanceInfo>>(RECENT_INSTANCES_KEY);
if (!stored || !Array.isArray(stored)) {
return [];
}
return stored.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, MAX_RECENT_INSTANCES);
}
function saveRecentInstance(domain: string, name?: string): void {
const recent = loadRecentInstances();
const normalizedDomain = domain.toLowerCase().trim();
const existingIndex = recent.findIndex((inst) => inst.domain.toLowerCase() === normalizedDomain);
if (existingIndex !== -1) {
recent.splice(existingIndex, 1);
}
recent.unshift({
domain: normalizedDomain,
name,
lastUsed: Date.now(),
});
AppStorage.setJSON(RECENT_INSTANCES_KEY, recent.slice(0, MAX_RECENT_INSTANCES));
}
function removeRecentInstance(domain: string): void {
const recent = loadRecentInstances();
const normalizedDomain = domain.toLowerCase().trim();
const filtered = recent.filter((inst) => inst.domain.toLowerCase() !== normalizedDomain);
AppStorage.setJSON(RECENT_INSTANCES_KEY, filtered);
}
export const InstanceSelector = observer(function InstanceSelector({
value,
onChange,
onInstanceDiscovered,
onDiscoveryStatusChange,
disabled = false,
className,
}: InstanceSelectorProps) {
const {t} = useLingui();
const [discoveryStatus, setDiscoveryStatus] = useState<InstanceDiscoveryStatus>('idle');
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
const [recentInstances, setRecentInstances] = useState<Array<InstanceInfo>>(() => loadRecentInstances());
const [showDropdown, setShowDropdown] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const discoveryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateDiscoveryStatus = useCallback(
(status: InstanceDiscoveryStatus) => {
setDiscoveryStatus(status);
onDiscoveryStatusChange?.(status);
},
[onDiscoveryStatusChange],
);
const discoverInstance = useCallback(
async (instanceUrl: string) => {
if (!instanceUrl.trim()) {
updateDiscoveryStatus('idle');
setDiscoveryError(null);
return;
}
updateDiscoveryStatus('discovering');
setDiscoveryError(null);
try {
await RuntimeConfigStore.connectToEndpoint(instanceUrl);
updateDiscoveryStatus('success');
saveRecentInstance(instanceUrl);
setRecentInstances(loadRecentInstances());
onInstanceDiscovered?.(instanceUrl);
} catch (error) {
updateDiscoveryStatus('error');
const errorMessage = error instanceof Error ? error.message : t`Failed to connect to instance`;
setDiscoveryError(errorMessage);
}
},
[onInstanceDiscovered, updateDiscoveryStatus, t],
);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
onChange(newValue);
updateDiscoveryStatus('idle');
setDiscoveryError(null);
if (discoveryTimeoutRef.current) {
clearTimeout(discoveryTimeoutRef.current);
}
if (newValue.trim()) {
discoveryTimeoutRef.current = setTimeout(() => {
discoverInstance(newValue);
}, 800);
}
},
[onChange, discoverInstance, updateDiscoveryStatus],
);
const handleSelectRecent = useCallback(
(instance: InstanceInfo) => {
onChange(instance.domain);
setShowDropdown(false);
discoverInstance(instance.domain);
},
[onChange, discoverInstance],
);
const handleRemoveRecent = useCallback((event: React.MouseEvent, domain: string) => {
event.stopPropagation();
removeRecentInstance(domain);
setRecentInstances(loadRecentInstances());
}, []);
const handleConnectClick = useCallback(() => {
if (value.trim()) {
discoverInstance(value);
}
}, [value, discoverInstance]);
const handleDropdownToggle = useCallback(() => {
if (recentInstances.length > 0 && !disabled) {
setShowDropdown((prev) => !prev);
}
}, [recentInstances.length, disabled]);
const handleInputFocus = useCallback(() => {
if (recentInstances.length > 0 && !value.trim()) {
setShowDropdown(true);
}
}, [recentInstances.length, value]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
useEffect(() => {
return () => {
if (discoveryTimeoutRef.current) {
clearTimeout(discoveryTimeoutRef.current);
}
};
}, []);
const statusIcon = useMemo(() => {
if (discoveryStatus === 'discovering') {
return <Spinner size="small" className={styles.statusSpinner} />;
}
if (discoveryStatus === 'success') {
return <CheckCircleIcon weight="fill" className={styles.statusSuccess} size={18} />;
}
if (discoveryStatus === 'error') {
return <WarningCircleIcon weight="fill" className={styles.statusError} size={18} />;
}
return null;
}, [discoveryStatus]);
const placeholder = t`Enter instance URL (e.g. fluxer.app)`;
return (
<div className={clsx(styles.container, className)}>
<div className={styles.inputContainer}>
<Input
ref={inputRef}
value={value}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder={placeholder}
disabled={disabled}
leftIcon={<GlobeIcon size={18} weight="regular" />}
rightElement={
<div className={styles.inputActions}>
{statusIcon}
{recentInstances.length > 0 && (
<button
type="button"
className={styles.dropdownToggle}
onClick={handleDropdownToggle}
disabled={disabled}
aria-label={t`Show recent instances`}
>
<CaretDownIcon
size={16}
weight="bold"
className={clsx(styles.caretIcon, showDropdown && styles.caretIconOpen)}
/>
</button>
)}
</div>
}
aria-label={t`Instance URL`}
aria-describedby={discoveryError ? 'instance-error' : undefined}
/>
{showDropdown && recentInstances.length > 0 && (
<div ref={dropdownRef} className={styles.dropdown}>
<div className={styles.dropdownHeader}>
<Trans>Recent instances</Trans>
</div>
<ul className={styles.dropdownList}>
{recentInstances.map((instance) => (
<li key={instance.domain}>
<button type="button" className={styles.dropdownItem} onClick={() => handleSelectRecent(instance)}>
<GlobeIcon size={16} weight="regular" className={styles.instanceIcon} />
<span className={styles.instanceDomain}>{instance.domain}</span>
{instance.name && <span className={styles.instanceName}>{instance.name}</span>}
<button
type="button"
className={styles.removeButton}
onClick={(e) => handleRemoveRecent(e, instance.domain)}
aria-label={t`Remove ${instance.domain} from recent instances`}
>
<TrashIcon size={14} weight="regular" />
</button>
</button>
</li>
))}
</ul>
</div>
)}
</div>
{discoveryError && (
<div id="instance-error" className={styles.errorMessage}>
{discoveryError}
</div>
)}
{discoveryStatus !== 'success' && value.trim() && (
<Button
onClick={handleConnectClick}
disabled={disabled}
submitting={discoveryStatus === 'discovering'}
variant="secondary"
small
className={styles.connectButton}
>
<Trans>Connect</Trans>
</Button>
)}
</div>
);
});

View File

@@ -17,20 +17,20 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/auth/AuthPageStyles.module.css';
import {GuildBadge} from '@app/components/guild/GuildBadge';
import {GuildIcon} from '@app/components/popouts/GuildIcon';
import {Avatar} from '@app/components/uikit/Avatar';
import {BaseAvatar} from '@app/components/uikit/BaseAvatar';
import {UserRecord} from '@app/records/UserRecord';
import {isGroupDmInvite, isGuildInvite, isPackInvite} from '@app/types/InviteTypes';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import {getCurrentLocale} from '@app/utils/LocaleUtils';
import {formatNumber} from '@fluxer/number_utils/src/NumberFormatting';
import type {GroupDmInvite, GuildInvite, Invite, PackInvite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {SealCheckIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {GuildFeatures} from '~/Constants';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {Avatar} from '~/components/uikit/Avatar';
import {BaseAvatar} from '~/components/uikit/BaseAvatar';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {UserRecord} from '~/records/UserRecord';
import type {GroupDmInvite, GuildInvite, Invite, PackInvite} from '~/types/InviteTypes';
import {isGroupDmInvite, isGuildInvite, isPackInvite} from '~/types/InviteTypes';
import * as AvatarUtils from '~/utils/AvatarUtils';
import styles from './AuthPageStyles.module.css';
import {useEffect, useMemo, useState} from 'react';
interface InviteHeaderProps {
invite: Invite;
@@ -52,19 +52,25 @@ interface PreviewGuildInviteHeaderProps {
guildId: string;
guildName: string;
guildIcon: string | null;
isVerified: boolean;
features: ReadonlyArray<string>;
presenceCount: number;
memberCount: number;
previewIconUrl?: string | null;
previewName?: string | null;
}
function formatInviteCount(value: number): string {
return formatNumber(value, getCurrentLocale());
}
export const GuildInviteHeader = observer(function GuildInviteHeader({invite}: GuildInviteHeaderProps) {
const {t} = useLingui();
const guild = invite.guild;
const features = Array.isArray(guild.features) ? guild.features : [...guild.features];
const isVerified = features.includes(GuildFeatures.VERIFIED);
const presenceCount = invite.presence_count ?? 0;
const memberCount = invite.member_count ?? 0;
const formattedPresenceCount = formatInviteCount(presenceCount);
const formattedMemberCount = formatInviteCount(memberCount);
return (
<div className={styles.entityHeader}>
@@ -77,23 +83,19 @@ export const GuildInviteHeader = observer(function GuildInviteHeader({invite}: G
</p>
<div className={styles.entityTitleWrapper}>
<h2 className={styles.entityTitle}>{guild.name}</h2>
{isVerified ? (
<Tooltip text={t`Verified Community`} position="top">
<SealCheckIcon className={styles.verifiedIcon} />
</Tooltip>
) : null}
<GuildBadge features={features} />
</div>
<div className={styles.entityStats}>
<div className={styles.entityStat}>
<div className={styles.onlineDot} />
<span className={styles.statText}>
<Trans>{invite.presence_count} Online</Trans>
<Trans>{formattedPresenceCount} Online</Trans>
</span>
</div>
<div className={styles.entityStat}>
<div className={styles.offlineDot} />
<span className={styles.statText}>
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
</span>
</div>
</div>
@@ -107,6 +109,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
const inviter = invite.inviter;
const avatarUrl = inviter ? AvatarUtils.getUserAvatarURL(inviter, false) : null;
const memberCount = invite.member_count ?? 0;
const formattedMemberCount = formatInviteCount(memberCount);
return (
<div className={styles.entityHeader}>
@@ -124,7 +127,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
<div className={styles.entityStat}>
<div className={styles.offlineDot} />
<span className={styles.statText}>
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
</span>
</div>
</div>
@@ -136,7 +139,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
export const PackInviteHeader = observer(function PackInviteHeader({invite}: PackInviteHeaderProps) {
const {t} = useLingui();
const pack = invite.pack;
const creatorRecord = React.useMemo(() => new UserRecord(pack.creator), [pack.creator]);
const creatorRecord = useMemo(() => new UserRecord(pack.creator), [pack.creator]);
const packKindLabel = pack.type === 'emoji' ? t`Emoji pack` : t`Sticker pack`;
const inviterTag = invite.inviter ? `${invite.inviter.username}#${invite.inviter.discriminator}` : null;
@@ -183,7 +186,7 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
guildId,
guildName,
guildIcon,
isVerified,
features,
presenceCount,
memberCount,
previewIconUrl,
@@ -191,9 +194,11 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
}: PreviewGuildInviteHeaderProps) {
const {t} = useLingui();
const displayName = previewName ?? guildName;
const [hasPreviewIconError, setPreviewIconError] = React.useState(false);
const formattedPresenceCount = formatInviteCount(presenceCount);
const formattedMemberCount = formatInviteCount(memberCount);
const [hasPreviewIconError, setPreviewIconError] = useState(false);
React.useEffect(() => {
useEffect(() => {
setPreviewIconError(false);
}, [previewIconUrl]);
@@ -222,23 +227,19 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
</p>
<div className={styles.entityTitleWrapper}>
<h2 className={styles.entityTitle}>{displayName}</h2>
{isVerified ? (
<Tooltip text={t`Verified Community`} position="top">
<SealCheckIcon className={styles.verifiedIcon} />
</Tooltip>
) : null}
<GuildBadge features={features} />
</div>
<div className={styles.entityStats}>
<div className={styles.entityStat}>
<div className={styles.onlineDot} />
<span className={styles.statText}>
<Trans>{presenceCount} Online</Trans>
<Trans>{formattedPresenceCount} Online</Trans>
</span>
</div>
<div className={styles.entityStat}>
<div className={styles.offlineDot} />
<span className={styles.statText}>
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
</span>
</div>
</div>

View File

@@ -17,15 +17,16 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
import styles from '@app/components/auth/IpAuthorizationScreen.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {Logger} from '@app/lib/Logger';
import type {IpAuthorizationChallenge} from '@app/viewmodels/auth/AuthFlow';
import {Trans} from '@lingui/react/macro';
import {EnvelopeSimpleIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {useCallback, useEffect, useRef, useState} from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
import {Button} from '~/components/uikit/Button/Button';
import type {IpAuthorizationChallenge} from '~/hooks/useLoginFlow';
import styles from './IpAuthorizationScreen.module.css';
type ConnectionState = 'connecting' | 'connected' | 'error';
type PollingState = 'polling' | 'error';
interface IpAuthorizationScreenProps {
challenge: IpAuthorizationChallenge;
@@ -33,79 +34,67 @@ interface IpAuthorizationScreenProps {
onBack?: () => void;
}
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 2000;
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_ERRORS = 3;
const logger = new Logger('IpAuthorizationScreen');
const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizationScreenProps) => {
const [resendUsed, setResendUsed] = useState(false);
const [resendIn, setResendIn] = useState(challenge.resendAvailableIn);
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting');
const [retryCount, setRetryCount] = useState(0);
const [pollingState, setPollingState] = useState<PollingState>('polling');
const onAuthorizedRef = useRef(onAuthorized);
onAuthorizedRef.current = onAuthorized;
useEffect(() => {
setResendUsed(false);
setResendIn(challenge.resendAvailableIn);
setConnectionState('connecting');
setRetryCount(0);
setPollingState('polling');
}, [challenge]);
useEffect(() => {
let es: EventSource | null = null;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
let isMounted = true;
let consecutiveErrors = 0;
const connect = () => {
const poll = async () => {
if (!isMounted) return;
es = AuthenticationActionCreators.subscribeToIpAuthorization(challenge.ticket);
try {
const result = await AuthenticationActionCreators.pollIpAuthorization(challenge.ticket);
es.onopen = () => {
if (isMounted) {
setConnectionState('connected');
setRetryCount(0);
}
};
es.onmessage = async (event) => {
if (!event.data) return;
try {
const data = JSON.parse(event.data);
if (data?.token && data?.user_id) {
es?.close();
await onAuthorizedRef.current({token: data.token, userId: data.user_id});
}
} catch {}
};
es.onerror = () => {
es?.close();
if (!isMounted) return;
setRetryCount((prev) => {
const newCount = prev + 1;
if (newCount < MAX_RETRY_ATTEMPTS) {
setConnectionState('connecting');
retryTimeout = setTimeout(connect, RETRY_DELAY_MS);
} else {
setConnectionState('error');
}
return newCount;
});
};
if (result.completed && result.token && result.user_id) {
await onAuthorizedRef.current({token: result.token, userId: result.user_id});
return;
}
consecutiveErrors = 0;
pollTimeout = setTimeout(poll, POLL_INTERVAL_MS);
} catch (error) {
if (!isMounted) return;
consecutiveErrors++;
if (consecutiveErrors >= MAX_POLL_ERRORS) {
setPollingState('error');
logger.error('Failed to poll IP authorization after max retries', error);
} else {
pollTimeout = setTimeout(poll, POLL_INTERVAL_MS);
}
}
};
connect();
poll();
return () => {
isMounted = false;
es?.close();
if (retryTimeout) {
clearTimeout(retryTimeout);
if (pollTimeout) {
clearTimeout(pollTimeout);
}
};
}, [challenge.ticket]);
}, [challenge.ticket, pollingState]);
useEffect(() => {
if (resendIn <= 0) return;
@@ -122,42 +111,36 @@ const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizatio
setResendUsed(true);
setResendIn(30);
} catch (error) {
console.error('Failed to resend IP authorization email', error);
logger.error('Failed to resend IP authorization email', error);
}
}, [challenge.ticket, resendIn, resendUsed]);
const handleRetryConnection = useCallback(() => {
setRetryCount(0);
setConnectionState('connecting');
const handleRetry = useCallback(() => {
setPollingState('polling');
}, []);
return (
<div className={styles.container}>
<div className={styles.icon}>
{connectionState === 'error' ? (
{pollingState === 'error' ? (
<WarningCircleIcon size={48} weight="fill" />
) : (
<EnvelopeSimpleIcon size={48} weight="fill" />
)}
</div>
<h1 className={styles.title}>
{connectionState === 'error' ? <Trans>Connection lost</Trans> : <Trans>Check your email</Trans>}
{pollingState === 'error' ? <Trans>Connection lost</Trans> : <Trans>Check your email</Trans>}
</h1>
<p className={styles.description}>
{connectionState === 'error' ? (
{pollingState === 'error' ? (
<Trans>We lost the connection while waiting for authorization. Please try again.</Trans>
) : (
<Trans>We emailed a link to authorize this login. Please open your inbox for {challenge.email}.</Trans>
)}
</p>
{connectionState === 'connecting' && retryCount > 0 && (
<p className={styles.retryingText}>
<Trans>Reconnecting...</Trans>
</p>
)}
<div className={styles.actions}>
{connectionState === 'error' ? (
<Button variant="primary" onClick={handleRetryConnection}>
{pollingState === 'error' ? (
<Button variant="primary" onClick={handleRetry}>
<Trans>Retry</Trans>
</Button>
) : (

View File

@@ -82,7 +82,6 @@
cursor: pointer;
}
.footerButton:hover,
.footerButton:focus {
.footerButton:hover {
color: var(--text-primary);
}

View File

@@ -17,12 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import FormField from '@app/components/auth/FormField';
import styles from '@app/components/auth/MfaScreen.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {useMfaController} from '@app/hooks/useLoginFlow';
import type {LoginSuccessPayload, MfaChallenge} from '@app/viewmodels/auth/AuthFlow';
import {Trans, useLingui} from '@lingui/react/macro';
import {useId} from 'react';
import FormField from '~/components/auth/FormField';
import {Button} from '~/components/uikit/Button/Button';
import {type LoginSuccessPayload, type MfaChallenge, useMfaController} from '~/hooks/useLoginFlow';
import styles from './MfaScreen.module.css';
interface MfaScreenProps {
challenge: MfaChallenge;

View File

@@ -17,42 +17,21 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import authStyles from '@app/components/auth/AuthPageStyles.module.css';
import dobStyles from '@app/components/auth/DateOfBirthField.module.css';
import {ExternalLink} from '@app/components/common/ExternalLink';
import inputStyles from '@app/components/form/Input.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/lib/PasswordManagerAutocomplete';
import {Routes} from '@app/Routes';
import {getCurrentLocale} from '@app/utils/LocaleUtils';
import {getDateFieldOrder} from '@fluxer/date_utils/src/DateIntrospection';
import {Trans, useLingui} from '@lingui/react/macro';
import {useMemo} from 'react';
import {ExternalLink} from '~/components/common/ExternalLink';
import inputStyles from '~/components/form/Input.module.css';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {Routes} from '~/Routes';
import {getCurrentLocale} from '~/utils/LocaleUtils';
import authStyles from './AuthPageStyles.module.css';
import dobStyles from './DateOfBirthField.module.css';
type DateFieldType = 'month' | 'day' | 'year';
function getDateFieldOrder(locale: string): Array<DateFieldType> {
const formatter = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = formatter.formatToParts(new Date(2000, 0, 1));
const order: Array<DateFieldType> = [];
for (const part of parts) {
if (part.type === 'month' && !order.includes('month')) {
order.push('month');
} else if (part.type === 'day' && !order.includes('day')) {
order.push('day');
} else if (part.type === 'year' && !order.includes('year')) {
order.push('year');
}
}
return order;
}
interface MockMinimalRegisterFormProps {
submitLabel: React.ReactNode;
}
@@ -65,17 +44,38 @@ export function MockMinimalRegisterForm({submitLabel}: MockMinimalRegisterFormPr
const dateFields: Record<DateFieldType, React.ReactElement> = {
month: (
<div key="month" className={dobStyles.monthField}>
<input type="text" readOnly tabIndex={-1} placeholder={t`Month`} className={inputStyles.input} />
<input
type="text"
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
readOnly
tabIndex={-1}
placeholder={t`Month`}
className={inputStyles.input}
/>
</div>
),
day: (
<div key="day" className={dobStyles.dayField}>
<input type="text" readOnly tabIndex={-1} placeholder={t`Day`} className={inputStyles.input} />
<input
type="text"
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
readOnly
tabIndex={-1}
placeholder={t`Day`}
className={inputStyles.input}
/>
</div>
),
year: (
<div key="year" className={dobStyles.yearField}>
<input type="text" readOnly tabIndex={-1} placeholder={t`Year`} className={inputStyles.input} />
<input
type="text"
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
readOnly
tabIndex={-1}
placeholder={t`Year`}
className={inputStyles.input}
/>
</div>
),
};
@@ -93,6 +93,7 @@ export function MockMinimalRegisterForm({submitLabel}: MockMinimalRegisterFormPr
<div className={inputStyles.inputGroup}>
<input
type="text"
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
readOnly
tabIndex={-1}
placeholder={t`What should people call you?`}

View File

@@ -17,12 +17,12 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/auth/SubmitTooltip.module.css';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import type {ReactNode} from 'react';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import styles from './SubmitTooltip.module.css';
export interface MissingField {
key: string;

View File

@@ -1,84 +0,0 @@
/*
* 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/>.
*/
.container {
overflow: hidden;
animation: slideDown 300ms ease-out;
}
.label {
color: var(--text-secondary);
font-size: 0.75rem;
line-height: 1rem;
margin-bottom: 0.5rem;
}
.suggestionsList {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.suggestionButton {
border-radius: 0.375rem;
background-color: var(--background-secondary-alt);
padding: 0.375rem 0.75rem;
color: var(--text-primary);
font-size: 0.75rem;
line-height: 1rem;
transition:
background-color 150ms ease,
transform 150ms ease;
animation: fadeInScale 200ms ease-out backwards;
border: none;
cursor: pointer;
}
.suggestionButton:hover {
background-color: var(--background-modifier-hover);
transform: translateY(-1px);
}
.suggestionButton:active {
transform: translateY(0);
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -1,59 +0,0 @@
/*
* 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 {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import styles from './UsernameSuggestions.module.css';
interface UsernameSuggestionsProps {
suggestions: Array<string>;
onSelect: (username: string) => void;
}
export const UsernameSuggestions = observer(function UsernameSuggestions({
suggestions,
onSelect,
}: UsernameSuggestionsProps) {
if (suggestions.length === 0) {
return null;
}
return (
<div className={styles.container}>
<p className={styles.label}>
<Trans>Suggested usernames:</Trans>
</p>
<div className={styles.suggestionsList}>
{suggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
onClick={() => onSelect(suggestion)}
className={styles.suggestionButton}
style={{
animationDelay: `${index * 50}ms`,
}}
>
{suggestion}
</button>
))}
</div>
</div>
);
});

View File

@@ -17,27 +17,27 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import FormField from '@app/components/auth/FormField';
import {Button} from '@app/components/uikit/button/Button';
import {Trans, useLingui} from '@lingui/react/macro';
import type React from 'react';
import {useId} from 'react';
import FormField from '~/components/auth/FormField';
import {Button} from '~/components/uikit/Button/Button';
type FieldErrors = Record<string, string | undefined> | null | undefined;
export type AuthFormControllerLike = {
export interface AuthFormControllerLike {
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
getValue: (name: string) => string;
setValue: (name: string, value: string) => void;
getError: (name: string) => string | null | undefined;
isSubmitting?: boolean;
};
}
export type AuthEmailPasswordFormClasses = {
export interface AuthEmailPasswordFormClasses {
form: string;
};
}
type Props = {
interface Props {
form: AuthFormControllerLike;
isLoading: boolean;
fieldErrors?: FieldErrors;
@@ -47,7 +47,7 @@ type Props = {
links?: React.ReactNode;
linksWrapperClassName?: string;
disableSubmit?: boolean;
};
}
export default function AuthLoginEmailPasswordForm({
form,

View File

@@ -17,15 +17,15 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Button} from '@app/components/uikit/button/Button';
import {Trans} from '@lingui/react/macro';
import {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
import {Button} from '~/components/uikit/Button/Button';
export type AuthLoginDividerClasses = {
interface AuthLoginDividerClasses {
divider: string;
dividerLine: string;
dividerText: string;
};
}
export function AuthLoginDivider({
classes,
@@ -43,11 +43,11 @@ export function AuthLoginDivider({
);
}
export type AuthPasskeyClasses = {
export interface AuthPasskeyClasses {
wrapper?: string;
};
}
type Props = {
interface Props {
classes?: AuthPasskeyClasses;
disabled: boolean;
@@ -58,7 +58,7 @@ type Props = {
primaryLabel?: React.ReactNode;
browserLabel?: React.ReactNode;
};
}
export default function AuthLoginPasskeyActions({
classes,

View File

@@ -17,17 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
import {useCallback, useMemo, useState} from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
export type DesktopHandoffMode = 'idle' | 'selecting' | 'login' | 'generating' | 'displaying' | 'error';
type DesktopHandoffMode = 'idle' | 'selecting' | 'login' | 'generating' | 'ready' | 'displaying' | 'error';
type Options = {
interface Options {
enabled: boolean;
hasStoredAccounts: boolean;
initialMode?: DesktopHandoffMode;
};
}
export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}: Options) {
const derivedInitial = useMemo<DesktopHandoffMode>(() => {