initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
/*
* 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 {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],
) => {
switch (alignment) {
case GuildSplashCardAlignment.LEFT:
return {transformOrigin: 'bottom left', objectPosition: 'left bottom'};
case GuildSplashCardAlignment.RIGHT:
return {transformOrigin: 'bottom right', objectPosition: 'right bottom'};
default:
return {transformOrigin: 'bottom center', objectPosition: 'center bottom'};
}
};
export interface AuthBackgroundProps {
splashUrl: string | null;
splashLoaded: boolean;
splashDimensions?: {width: number; height: number} | null;
splashScale?: number | null;
patternReady: boolean;
patternImageUrl: string;
className?: string;
useFullCover?: boolean;
splashAlignment?: (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment];
}
export const AuthBackground: React.FC<AuthBackgroundProps> = ({
splashUrl,
splashLoaded,
splashDimensions,
splashScale,
patternReady,
patternImageUrl,
className,
useFullCover = false,
splashAlignment = GuildSplashCardAlignment.CENTER,
}) => {
const shouldShowSplash = splashUrl && splashDimensions && (useFullCover || splashScale);
const {transformOrigin, objectPosition} = getSplashAlignmentStyles(splashAlignment);
if (shouldShowSplash) {
if (useFullCover) {
return (
<div className={className}>
<motion.div
initial={{opacity: 0}}
animate={{opacity: splashLoaded ? 1 : 0}}
transition={{duration: 0.5, ease: 'easeInOut'}}
style={{position: 'absolute', inset: 0}}
>
<img
src={splashUrl}
alt=""
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition,
}}
/>
<div className={styles.splashOverlay} />
</motion.div>
</div>
);
}
return (
<div className={styles.rightSplit}>
<motion.div
className={styles.splashImage}
initial={{opacity: 0}}
animate={{opacity: splashLoaded ? 1 : 0}}
transition={{duration: 0.5, ease: 'easeInOut'}}
style={{
width: splashDimensions.width,
height: splashDimensions.height,
transform: `scale(${splashScale})`,
transformOrigin,
}}
>
<img
src={splashUrl}
alt=""
width={splashDimensions.width}
height={splashDimensions.height}
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition,
}}
/>
<div className={styles.splashOverlay} />
</motion.div>
</div>
);
}
if (patternReady) {
return (
<div
className={className || styles.patternHost}
style={{backgroundImage: `url(${patternImageUrl})`}}
aria-hidden
/>
);
}
return null;
};

View File

@@ -0,0 +1,48 @@
/*
* 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 styles from './AuthPageStyles.module.css';
import {AuthRouterLink} from './AuthRouterLink';
interface AuthBottomLinkProps {
variant: 'login' | 'register';
to: string;
}
export function AuthBottomLink({variant, to}: AuthBottomLinkProps) {
return (
<div className={styles.bottomLink}>
<span className={styles.bottomLinkText}>
{variant === 'login' ? <Trans>Already have an account?</Trans> : <Trans>Need an account?</Trans>}{' '}
</span>
<AuthRouterLink to={to} className={styles.bottomLinkAnchor}>
{variant === 'login' ? <Trans>Log in</Trans> : <Trans>Register</Trans>}
</AuthRouterLink>
</div>
);
}
interface AuthBottomLinksProps {
children: React.ReactNode;
}
export function AuthBottomLinks({children}: AuthBottomLinksProps) {
return <div className={styles.bottomLinks}>{children}</div>;
}

View File

@@ -0,0 +1,36 @@
/*
* 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/>.
*/
.inertOverlay {
pointer-events: none;
}
.inertOverlay * {
pointer-events: none !important;
cursor: default !important;
user-select: none;
}
.inertOverlay input,
.inertOverlay button,
.inertOverlay select,
.inertOverlay textarea,
.inertOverlay a {
opacity: 0.75;
}

View File

@@ -0,0 +1,50 @@
/*
* 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 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 {
showLogoSide?: boolean;
children: ReactNode;
isInert?: boolean;
className?: string;
}
export function AuthCardContainer({showLogoSide = true, children, isInert = false, className}: AuthCardContainerProps) {
return (
<div className={clsx(authLayoutStyles.cardContainer, className)}>
<div className={clsx(authLayoutStyles.card, !showLogoSide && authLayoutStyles.cardSingle)}>
{showLogoSide && (
<div className={authLayoutStyles.logoSide}>
<FluxerLogo className={authLayoutStyles.logo} />
<FluxerWordmark className={authLayoutStyles.wordmark} />
</div>
)}
<div className={clsx(authLayoutStyles.formSide, !showLogoSide && authLayoutStyles.formSideSingle)}>
{isInert ? <div className={styles.inertOverlay}>{children}</div> : children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
/*
* 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 type {Icon} from '@phosphor-icons/react';
import {QuestionIcon} from '@phosphor-icons/react';
import styles from './AuthPageStyles.module.css';
interface AuthErrorStateProps {
icon?: Icon;
title: React.ReactNode;
text: React.ReactNode;
}
export function AuthErrorState({icon: IconComponent = QuestionIcon, title, text}: AuthErrorStateProps) {
return (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}>
<IconComponent className={styles.errorIconSvg} />
</div>
<h1 className={styles.errorTitle}>{title}</h1>
<p className={styles.errorText}>{text}</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
/*
* 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 {Spinner} from '~/components/uikit/Spinner';
import styles from './AuthPageStyles.module.css';
export function AuthLoadingState() {
return (
<div className={styles.loadingContainer}>
<Spinner />
</div>
);
}

View File

@@ -0,0 +1,105 @@
/*
* 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, 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 = {
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 = {
form: string;
};
type Props = {
form: AuthFormControllerLike;
isLoading: boolean;
fieldErrors?: FieldErrors;
submitLabel: React.ReactNode;
classes: AuthEmailPasswordFormClasses;
extraFields?: React.ReactNode;
links?: React.ReactNode;
linksWrapperClassName?: string;
disableSubmit?: boolean;
};
export default function AuthLoginEmailPasswordForm({
form,
isLoading,
fieldErrors,
submitLabel,
classes,
extraFields,
links,
linksWrapperClassName,
disableSubmit,
}: Props) {
const {t} = useLingui();
const emailId = useId();
const passwordId = useId();
const isSubmitting = Boolean(form.isSubmitting);
const submitDisabled = isLoading || isSubmitting || Boolean(disableSubmit);
return (
<form className={classes.form} onSubmit={form.handleSubmit}>
<FormField
id={emailId}
name="email"
type="email"
autoComplete="email"
required
label={t`Email`}
value={form.getValue('email')}
onChange={(value) => form.setValue('email', value)}
error={form.getError('email') || fieldErrors?.email}
/>
<FormField
id={passwordId}
name="password"
type="password"
autoComplete="current-password"
required
label={t`Password`}
value={form.getValue('password')}
onChange={(value) => form.setValue('password', value)}
error={form.getError('password') || fieldErrors?.password}
/>
{extraFields}
{links ? <div className={linksWrapperClassName}>{links}</div> : null}
<Button type="submit" fitContainer disabled={submitDisabled}>
{typeof submitLabel === 'string' ? <Trans>{submitLabel}</Trans> : submitLabel}
</Button>
</form>
);
}

View File

@@ -0,0 +1,99 @@
/*
* 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 {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
import {Button} from '~/components/uikit/Button/Button';
export type AuthLoginDividerClasses = {
divider: string;
dividerLine: string;
dividerText: string;
};
export function AuthLoginDivider({
classes,
label = <Trans>OR</Trans>,
}: {
classes: AuthLoginDividerClasses;
label?: React.ReactNode;
}) {
return (
<div className={classes.divider}>
<div className={classes.dividerLine} />
<span className={classes.dividerText}>{label}</span>
<div className={classes.dividerLine} />
</div>
);
}
export type AuthPasskeyClasses = {
wrapper?: string;
};
type Props = {
classes?: AuthPasskeyClasses;
disabled: boolean;
onPasskeyLogin: () => void;
showBrowserOption: boolean;
onBrowserLogin?: () => void;
primaryLabel?: React.ReactNode;
browserLabel?: React.ReactNode;
};
export default function AuthLoginPasskeyActions({
classes,
disabled,
onPasskeyLogin,
showBrowserOption,
onBrowserLogin,
primaryLabel = <Trans>Log in with a passkey</Trans>,
browserLabel = <Trans>Log in via browser</Trans>,
}: Props) {
return (
<div className={classes?.wrapper}>
<Button
type="button"
fitContainer
variant="secondary"
onClick={onPasskeyLogin}
disabled={disabled}
leftIcon={<KeyIcon size={16} />}
>
{primaryLabel}
</Button>
{showBrowserOption && onBrowserLogin ? (
<Button
type="button"
fitContainer
variant="secondary"
onClick={onBrowserLogin}
disabled={disabled}
leftIcon={<BrowserIcon size={16} />}
>
{browserLabel}
</Button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,91 @@
/*
* 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 {useCallback, useMemo, useState} from 'react';
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
export type DesktopHandoffMode = 'idle' | 'selecting' | 'login' | 'generating' | 'displaying' | 'error';
type Options = {
enabled: boolean;
hasStoredAccounts: boolean;
initialMode?: DesktopHandoffMode;
};
export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}: Options) {
const derivedInitial = useMemo<DesktopHandoffMode>(() => {
if (!enabled) return 'idle';
if (initialMode) return initialMode;
return hasStoredAccounts ? 'selecting' : 'login';
}, [enabled, hasStoredAccounts, initialMode]);
const [mode, setMode] = useState<DesktopHandoffMode>(derivedInitial);
const [code, setCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const start = useCallback(
async ({token, userId}: {token: string; userId: string}) => {
if (!enabled) return;
setMode('generating');
setError(null);
setCode(null);
try {
const result = await AuthenticationActionCreators.initiateDesktopHandoff();
await AuthenticationActionCreators.completeDesktopHandoff({
code: result.code,
token,
userId,
});
setCode(result.code);
setMode('displaying');
} catch (e) {
setMode('error');
setError(e instanceof Error ? e.message : String(e));
}
},
[enabled],
);
const switchToLogin = useCallback(() => {
setMode('login');
setError(null);
}, []);
const retry = useCallback(() => {
setError(null);
setCode(null);
setMode(hasStoredAccounts ? 'selecting' : 'login');
}, [hasStoredAccounts]);
return {
mode,
code,
error,
setMode,
start,
switchToLogin,
retry,
};
}

View File

@@ -0,0 +1,285 @@
/*
* 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, 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;
inviteCode?: string;
desktopHandoff?: boolean;
excludeCurrentUser?: boolean;
extraTopContent?: ReactNode;
showTitle?: boolean;
title?: ReactNode;
registerLink: ReactElement<Record<string, unknown>>;
onLoginComplete?: (payload: LoginSuccessPayload) => Promise<void> | void;
initialEmail?: string;
}
const AuthLoginLayout = observer(function AuthLoginLayout({
redirectPath,
inviteCode,
desktopHandoff = false,
excludeCurrentUser = false,
extraTopContent,
showTitle = true,
title,
registerLink,
onLoginComplete,
initialEmail,
}: AuthLoginLayoutProps) {
const {t} = useLingui();
const currentUserId = AccountManager.currentUserId;
const accounts = AccountManager.orderedAccounts;
const hasStoredAccounts = accounts.length > 0;
const handoffAccounts =
desktopHandoff && excludeCurrentUser ? accounts.filter((a) => a.userId !== currentUserId) : accounts;
const hasHandoffAccounts = handoffAccounts.length > 0;
const handoff = useDesktopHandoffFlow({
enabled: desktopHandoff,
hasStoredAccounts: hasHandoffAccounts,
initialMode: desktopHandoff && hasHandoffAccounts ? 'selecting' : 'login',
});
const [ipAuthChallenge, setIpAuthChallenge] = useState<IpAuthorizationChallenge | null>(null);
const [showAccountSelector, setShowAccountSelector] = useState(!desktopHandoff && hasStoredAccounts && !initialEmail);
const [isSwitching, setIsSwitching] = useState(false);
const [switchError, setSwitchError] = useState<string | null>(null);
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
const showLoginFormForAccount = useCallback((account: AccountSummary, message?: string | null) => {
setShowAccountSelector(false);
setSwitchError(message ?? null);
setPrefillEmail(account.userData?.email ?? null);
}, []);
const handleLoginSuccess = useCallback(
async ({token, userId}: LoginSuccessPayload) => {
if (desktopHandoff) {
await handoff.start({token, userId});
return;
}
await AuthenticationActionCreators.completeLogin({token, userId});
await onLoginComplete?.({token, userId});
},
[desktopHandoff, handoff, onLoginComplete],
);
const {form, isLoading, fieldErrors, handlePasskeyLogin, handlePasskeyBrowserLogin, isPasskeyLoading} =
useLoginFormController({
redirectPath,
inviteCode,
onLoginSuccess: handleLoginSuccess,
onRequireMfa: (challenge) => {
AuthenticationActionCreators.setMfaTicket(challenge);
},
onRequireIpAuthorization: (challenge) => {
setIpAuthChallenge(challenge);
},
});
const showBrowserPasskey = IS_DEV || isDesktop();
const passkeyControlsDisabled = isLoading || Boolean(form.isSubmitting) || isPasskeyLoading;
const handleIpAuthorizationComplete = useCallback(
async ({token, userId}: LoginSuccessPayload) => {
await handleLoginSuccess({token, userId});
if (redirectPath) {
RouterUtils.replaceWith(redirectPath);
}
setIpAuthChallenge(null);
},
[handleLoginSuccess, redirectPath],
);
useEffect(() => {
setPrefillEmail(initialEmail ?? null);
if (initialEmail) {
setShowAccountSelector(false);
}
}, [initialEmail]);
useEffect(() => {
if (prefillEmail !== null) {
form.setValue('email', prefillEmail);
}
}, [form, prefillEmail]);
const handleSelectExistingAccount = useCallback(
async (account: AccountSummary) => {
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
const expiredMessage = t`Session expired for ${identifier}. Please log in again.`;
if (account.isValid === false || !AccountManager.canSwitchAccounts) {
showLoginFormForAccount(account, expiredMessage);
return;
}
setIsSwitching(true);
setSwitchError(null);
try {
await AccountManager.switchToAccount(account.userId);
} catch (error) {
const updatedAccount = AccountManager.accounts.get(account.userId);
if (error instanceof SessionExpiredError || updatedAccount?.isValid === false) {
showLoginFormForAccount(updatedAccount ?? account, expiredMessage);
return;
}
setSwitchError(error instanceof Error ? error.message : t`Failed to switch account`);
} finally {
setIsSwitching(false);
}
},
[showLoginFormForAccount],
);
const handleAddAnotherAccount = useCallback(() => {
setShowAccountSelector(false);
setSwitchError(null);
setPrefillEmail(null);
}, []);
const styledRegisterLink = useMemo(() => {
const {className: linkClassName} = registerLink.props as {className?: string};
return cloneElement(registerLink, {
className: clsx(styles.footerLink, linkClassName),
});
}, [registerLink]);
if (desktopHandoff && handoff.mode === 'selecting') {
return (
<DesktopHandoffAccountSelector
excludeCurrentUser={excludeCurrentUser}
onSelectNewAccount={handoff.switchToLogin}
/>
);
}
if (showAccountSelector && hasStoredAccounts && !desktopHandoff) {
return (
<AccountSelector
accounts={accounts}
currentAccountId={currentUserId}
error={switchError}
disabled={isSwitching}
showInstance
onSelectAccount={handleSelectExistingAccount}
onAddAccount={handleAddAnotherAccount}
/>
);
}
if (desktopHandoff && (handoff.mode === 'generating' || handoff.mode === 'displaying' || handoff.mode === 'error')) {
return (
<HandoffCodeDisplay
code={handoff.code}
isGenerating={handoff.mode === 'generating'}
error={handoff.mode === 'error' ? handoff.error : null}
onRetry={handoff.retry}
/>
);
}
if (ipAuthChallenge) {
return (
<IpAuthorizationScreen
challenge={ipAuthChallenge}
onAuthorized={handleIpAuthorizationComplete}
onBack={() => setIpAuthChallenge(null)}
/>
);
}
return (
<>
{extraTopContent}
{showTitle ? <h1 className={styles.title}>{title ?? <Trans>Welcome back</Trans>}</h1> : null}
{!showAccountSelector && switchError ? <div className={styles.loginNotice}>{switchError}</div> : null}
<AuthLoginEmailPasswordForm
form={form}
isLoading={isLoading}
fieldErrors={fieldErrors}
submitLabel={<Trans>Log in</Trans>}
classes={{form: styles.form}}
linksWrapperClassName={styles.formLinks}
links={
<AuthRouterLink to="/forgot" className={styles.link}>
<Trans>Forgot your password?</Trans>
</AuthRouterLink>
}
disableSubmit={isPasskeyLoading}
/>
<AuthLoginDivider
classes={{
divider: styles.divider,
dividerLine: styles.dividerLine,
dividerText: styles.dividerText,
}}
/>
<AuthLoginPasskeyActions
classes={{
wrapper: styles.passkeyActions,
}}
disabled={passkeyControlsDisabled}
onPasskeyLogin={handlePasskeyLogin}
showBrowserOption={showBrowserPasskey}
onBrowserLogin={handlePasskeyBrowserLogin}
browserLabel={<Trans>Log in via browser or custom instance</Trans>}
/>
<div className={styles.footer}>
<div className={styles.footerText}>
<span className={styles.footerLabel}>
<Trans>Need an account?</Trans>{' '}
</span>
{styledRegisterLink}
</div>
</div>
</>
);
});
export {AuthLoginLayout};

View File

@@ -0,0 +1,151 @@
/*
* 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, 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';
interface AuthMinimalRegisterFormCoreProps {
submitLabel: React.ReactNode;
redirectPath: string;
onRegister?: (response: {token: string; user_id: string}) => Promise<void>;
inviteCode?: string;
extraContent?: React.ReactNode;
}
export function AuthMinimalRegisterFormCore({
submitLabel,
redirectPath,
onRegister,
inviteCode,
extraContent,
}: AuthMinimalRegisterFormCoreProps) {
const {t} = useLingui();
const globalNameId = useId();
const [selectedMonth, setSelectedMonth] = useState('');
const [selectedDay, setSelectedDay] = useState('');
const [selectedYear, setSelectedYear] = useState('');
const [consent, setConsent] = useState(false);
const initialValues: Record<string, string> = {
global_name: '',
};
const handleRegisterSubmit = async (values: Record<string, string>) => {
const dateOfBirth =
selectedYear && selectedMonth && selectedDay
? `${selectedYear}-${selectedMonth.padStart(2, '0')}-${selectedDay.padStart(2, '0')}`
: '';
const response = await AuthenticationActionCreators.register({
global_name: values.global_name || undefined,
beta_code: '',
date_of_birth: dateOfBirth,
consent,
invite_code: inviteCode,
});
if (onRegister) {
await onRegister(response);
} else {
await AuthenticationActionCreators.completeLogin({
token: response.token,
userId: response.user_id,
});
}
};
const {form, isLoading, fieldErrors} = useAuthForm({
initialValues,
onSubmit: handleRegisterSubmit,
redirectPath,
firstFieldName: 'global_name',
});
const missingFields = useMemo(() => {
const missing: Array<MissingField> = [];
if (!selectedMonth || !selectedDay || !selectedYear) {
missing.push({key: 'date_of_birth', label: t`Date of birth`});
}
return missing;
}, [selectedMonth, selectedDay, selectedYear]);
const globalNameValue = form.getValue('global_name');
return (
<form className={styles.form} onSubmit={form.handleSubmit}>
<FormField
id={globalNameId}
name="global_name"
type="text"
label={t`Display name (optional)`}
placeholder={t`What should people call you?`}
value={globalNameValue}
onChange={(value) => form.setValue('global_name', value)}
error={form.getError('global_name') || fieldErrors?.global_name}
/>
<DateOfBirthField
selectedMonth={selectedMonth}
selectedDay={selectedDay}
selectedYear={selectedYear}
onMonthChange={setSelectedMonth}
onDayChange={setSelectedDay}
onYearChange={setSelectedYear}
error={fieldErrors?.date_of_birth}
/>
{extraContent}
<div className={styles.consentRow}>
<Checkbox checked={consent} onChange={setConsent}>
<span className={styles.consentLabel}>
<Trans>I agree to the</Trans>{' '}
<ExternalLink href={Routes.terms()} className={styles.policyLink}>
<Trans>Terms of Service</Trans>
</ExternalLink>{' '}
<Trans>and</Trans>{' '}
<ExternalLink href={Routes.privacy()} className={styles.policyLink}>
<Trans>Privacy Policy</Trans>
</ExternalLink>
</span>
</Checkbox>
</div>
<SubmitTooltip consent={consent} missingFields={missingFields}>
<Button
type="submit"
fitContainer
disabled={isLoading || form.isSubmitting || shouldDisableSubmit(consent, missingFields)}
>
{submitLabel}
</Button>
</SubmitTooltip>
</form>
);
}

View File

@@ -0,0 +1,68 @@
/*
* 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 {useLingui} from '@lingui/react/macro';
import {SealCheckIcon} from '@phosphor-icons/react';
import type {ReactNode} from 'react';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import styles from './AuthPageStyles.module.css';
interface AuthPageHeaderStatProps {
value: string | number;
dot?: 'online' | 'offline';
}
interface AuthPageHeaderProps {
icon: ReactNode;
title: string;
subtitle: string;
verified?: boolean;
stats?: Array<AuthPageHeaderStatProps>;
}
export function AuthPageHeader({icon, title, subtitle, verified, stats}: AuthPageHeaderProps) {
const {t} = useLingui();
return (
<div className={styles.entityHeader}>
{icon}
<div className={styles.entityDetails}>
<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>
)}
</div>
{stats && stats.length > 0 && (
<div className={styles.entityStats}>
{stats.map((stat, index) => (
<div key={index} className={styles.entityStat}>
{stat.dot === 'online' && <div className={styles.onlineDot} />}
{stat.dot === 'offline' && <div className={styles.offlineDot} />}
<span className={styles.statText}>{stat.value}</span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,429 @@
/*
* 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/>.
*/
.loadingContainer {
display: flex;
min-height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
}
.errorContainer {
display: flex;
min-height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.errorIcon {
display: flex;
height: 5rem;
width: 5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: var(--background-tertiary);
}
.errorIconSvg {
height: 2.5rem;
width: 2.5rem;
color: var(--text-tertiary);
}
.errorTitle {
text-align: center;
font-weight: 600;
font-size: 1.25rem;
color: var(--text-primary);
}
.errorText {
text-align: center;
font-size: 0.875rem;
color: var(--text-tertiary);
}
.container {
display: flex;
min-height: 0;
flex: 1 1 0%;
flex-direction: column;
}
.entityHeader {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
}
.entityDetails {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.entityText {
font-size: 0.875rem;
color: var(--text-secondary);
}
.entityTitleWrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.packBadge {
background: var(--background-modifier-accent);
border-radius: 999px;
padding: 0.15rem 0.6rem;
font-size: 0.75rem;
color: var(--text-primary);
font-weight: 600;
}
.entityTitle {
font-weight: 700;
font-size: 1.25rem;
color: var(--text-primary);
}
.verifiedIcon {
height: 1.5rem;
width: 1.5rem;
color: var(--text-primary);
}
.entityStats {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.packDescription {
font-size: 0.95rem;
color: var(--text-secondary);
line-height: 1.4;
margin: 0.25rem 0;
}
.packMeta {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.packMetaText {
font-size: 0.78rem;
color: var(--text-tertiary);
}
.entityStat {
display: flex;
align-items: center;
}
.onlineDot {
margin-right: 0.375rem;
height: 0.625rem;
width: 0.625rem;
border-radius: 9999px;
background-color: var(--status-online);
}
.offlineDot {
margin-right: 0.375rem;
height: 0.625rem;
width: 0.625rem;
border-radius: 9999px;
background-color: var(--text-tertiary-secondary);
}
.statText {
font-size: 0.875rem;
color: var(--text-tertiary);
}
.entityIconWrapper {
width: 5rem;
height: 5rem;
min-width: 5rem;
min-height: 5rem;
border-radius: 9999px;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.entityIcon {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: var(--background-primary);
object-fit: cover;
}
.themeIconSpot {
display: flex;
height: 5rem;
width: 5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-dark, #4752c4) 100%);
}
.themeIcon {
height: 2.5rem;
width: 2.5rem;
color: white;
}
.form {
margin-top: 1.5rem;
flex: 1 1 0%;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.loginForm {
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bottomLink {
margin-top: 1rem;
text-align: left;
}
.bottomLinks {
margin-top: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bottomLinkText {
font-size: 0.875rem;
color: var(--text-tertiary);
}
.bottomLinkAnchor {
font-size: 0.875rem;
color: var(--text-link);
transition-property: color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
}
.bottomLinkAnchor:hover {
color: var(--text-link);
text-decoration: underline;
}
.divider {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.dividerLine {
flex: 1 1 0%;
border-top: 1px solid var(--background-modifier-accent);
}
.dividerText {
font-size: 0.875rem;
color: var(--text-tertiary);
}
.forgotPasswordLink {
text-align: left;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.forgotPasswordLinkText {
font-size: 0.875rem;
color: var(--text-tertiary);
transition-property: color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
}
.forgotPasswordLinkText:hover {
color: var(--text-primary);
text-decoration: underline;
}
.usernameHint {
margin-top: 0.25rem;
display: block;
font-size: 0.75rem;
color: var(--text-tertiary);
}
.consentRow {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.consentLabel {
padding-top: 2px;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
}
.policyLink {
color: var(--text-link);
text-decoration: none;
transition-property: color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.policyLink:hover {
text-decoration: underline;
}
.submitSpacer {
height: 4px;
}
.disabledContainer {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: center;
}
.disabledText {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.disabledSubtext {
font-size: 0.875rem;
color: var(--text-tertiary);
line-height: 1.5;
}
.disabledActions {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.disabledActionLink {
display: block;
text-decoration: none;
}
.title {
margin-bottom: 1.5rem;
text-align: center;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 600;
letter-spacing: 0.025em;
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;
width: 5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: linear-gradient(to bottom right, rgb(168, 85, 247), rgb(236, 72, 153));
}
.giftIcon {
height: 2.5rem;
width: 2.5rem;
color: white;
}
.entitySubtext {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.subtext {
margin-top: 0.75rem;
text-align: center;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text-tertiary);
}
.secondaryInlineAction {
padding: 0;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: var(--text-link);
cursor: pointer;
}
.secondaryInlineAction:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,285 @@
/*
* 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, 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';
interface FieldConfig {
showEmail?: boolean;
showPassword?: boolean;
showUsernameValidation?: boolean;
showBetaCodeHint?: boolean;
requireBetaCode?: boolean;
}
interface AuthRegisterFormCoreProps {
fields?: FieldConfig;
submitLabel: React.ReactNode;
redirectPath: string;
onRegister?: (response: {token: string; user_id: string}) => Promise<void>;
inviteCode?: string;
extraContent?: React.ReactNode;
}
export function AuthRegisterFormCore({
fields = {},
submitLabel,
redirectPath,
onRegister,
inviteCode,
extraContent,
}: AuthRegisterFormCoreProps) {
const {t} = useLingui();
const {
showEmail = false,
showPassword = false,
showUsernameValidation = false,
requireBetaCode = MODE !== 'development',
} = fields;
const emailId = useId();
const globalNameId = useId();
const usernameId = useId();
const passwordId = useId();
const betaCodeId = useId();
const [selectedMonth, setSelectedMonth] = useState('');
const [selectedDay, setSelectedDay] = useState('');
const [selectedYear, setSelectedYear] = useState('');
const [consent, setConsent] = useState(false);
const [usernameFocused, setUsernameFocused] = useState(false);
const initialValues: Record<string, string> = {
global_name: '',
username: '',
betaCode: '',
};
if (showEmail) initialValues.email = '';
if (showPassword) initialValues.password = '';
const handleRegisterSubmit = async (values: Record<string, string>) => {
const dateOfBirth =
selectedYear && selectedMonth && selectedDay
? `${selectedYear}-${selectedMonth.padStart(2, '0')}-${selectedDay.padStart(2, '0')}`
: '';
const response = await AuthenticationActionCreators.register({
global_name: values.global_name || undefined,
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,
});
if (onRegister) {
await onRegister(response);
} else {
await AuthenticationActionCreators.completeLogin({
token: response.token,
userId: response.user_id,
});
}
};
const {form, isLoading, fieldErrors} = useAuthForm({
initialValues,
onSubmit: handleRegisterSubmit,
redirectPath,
firstFieldName: showEmail ? 'email' : 'global_name',
});
const {suggestions} = useUsernameSuggestions({
globalName: form.getValue('global_name'),
username: form.getValue('username'),
});
const missingFields = useMemo(() => {
const missing: Array<MissingField> = [];
if (showEmail && !form.getValue('email')) {
missing.push({key: 'email', label: t`Email`});
}
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 (requireBetaCode && !form.getValue('betaCode')) {
missing.push({key: 'betaCode', label: t`Beta code`});
}
return missing;
}, [form, selectedMonth, selectedDay, selectedYear, showEmail, showPassword, requireBetaCode]);
const usernameValue = form.getValue('username');
const showValidationRules = showUsernameValidation && usernameValue && (usernameFocused || usernameValue.length > 0);
return (
<form className={styles.form} onSubmit={form.handleSubmit}>
{showEmail && (
<FormField
id={emailId}
name="email"
type="email"
autoComplete="email"
required
label={t`Email`}
value={form.getValue('email')}
onChange={(value) => form.setValue('email', value)}
error={form.getError('email') || fieldErrors?.email}
/>
)}
<FormField
id={globalNameId}
name="global_name"
type="text"
label={t`Display name (optional)`}
placeholder={t`What should people call you?`}
value={form.getValue('global_name')}
onChange={(value) => form.setValue('global_name', value)}
error={form.getError('global_name') || fieldErrors?.global_name}
/>
<div>
<FormField
id={usernameId}
name="username"
type="text"
autoComplete="username"
label={t`Username (optional)`}
placeholder={t`Leave blank for a random username`}
value={usernameValue}
onChange={(value) => form.setValue('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>
)}
{!usernameValue && (
<UsernameSuggestions suggestions={suggestions} onSelect={(username) => form.setValue('username', username)} />
)}
{showPassword && (
<FormField
id={passwordId}
name="password"
type="password"
autoComplete="new-password"
required
label={t`Password`}
value={form.getValue('password')}
onChange={(value) => form.setValue('password', value)}
error={form.getError('password') || fieldErrors?.password}
/>
)}
{requireBetaCode ? (
<FormField
id={betaCodeId}
name="betaCode"
type="text"
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}
/>
)}
<DateOfBirthField
selectedMonth={selectedMonth}
selectedDay={selectedDay}
selectedYear={selectedYear}
onMonthChange={setSelectedMonth}
onDayChange={setSelectedDay}
onYearChange={setSelectedYear}
error={fieldErrors?.date_of_birth}
/>
{extraContent}
<div className={styles.consentRow}>
<Checkbox checked={consent} onChange={setConsent}>
<span className={styles.consentLabel}>
<Trans>I agree to the</Trans>{' '}
<ExternalLink href={Routes.terms()} className={styles.policyLink}>
<Trans>Terms of Service</Trans>
</ExternalLink>{' '}
<Trans>and</Trans>{' '}
<ExternalLink href={Routes.privacy()} className={styles.policyLink}>
<Trans>Privacy Policy</Trans>
</ExternalLink>
</span>
</Checkbox>
</div>
<SubmitTooltip consent={consent} missingFields={missingFields}>
<Button
type="submit"
fitContainer
disabled={isLoading || form.isSubmitting || shouldDisableSubmit(consent, missingFields)}
>
{submitLabel}
</Button>
</SubmitTooltip>
</form>
);
}

View File

@@ -0,0 +1,40 @@
/*
* 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 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>;
}
export function AuthRouterLink({ringOffset = -2, children, className, to, search}: AuthRouterLinkProps) {
return (
<FocusRing offset={ringOffset}>
<RouterLink tabIndex={0} className={className} to={to} search={search}>
{children}
</RouterLink>
</FocusRing>
);
}

View File

@@ -0,0 +1,102 @@
/*
* 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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 20px;
}
.description {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.codeInputSection {
padding: 16px 0;
}
.inputHelper {
font-size: 0.8125rem;
color: var(--text-muted);
margin: 8px 0 0;
line-height: 1.4;
}
.instanceLink {
display: block;
width: 100%;
background: none;
border: none;
color: var(--text-muted);
font-size: 0.8125rem;
cursor: pointer;
padding: 0;
text-align: center;
}
.instanceLink:hover {
color: var(--text-muted);
text-decoration: underline;
}
.prefillHint {
font-size: 0.875rem;
color: var(--text-secondary);
text-align: center;
margin: 0;
}
.instanceBadge {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 14px;
border-radius: 6px;
}
.instanceBadgeIcon {
color: var(--status-online);
flex-shrink: 0;
}
.instanceBadgeText {
font-size: 0.8125rem;
color: var(--status-online);
font-weight: 500;
}
.instanceBadgeClear {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 2px 6px;
margin-left: 4px;
border-radius: 4px;
transition: color 0.15s ease;
}
.instanceBadgeClear:hover {
color: var(--text-primary);
}

View File

@@ -0,0 +1,352 @@
/*
* 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 {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowSquareOutIcon, CheckCircleIcon} 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';
interface LoginSuccessPayload {
token: string;
userId: string;
}
interface BrowserLoginHandoffModalProps {
onSuccess: (payload: LoginSuccessPayload) => Promise<void>;
targetWebAppUrl?: string;
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, '')
.toUpperCase()
.slice(0, CODE_LENGTH);
if (cleaned.length <= 4) {
return cleaned;
}
return `${cleaned.slice(0, 4)}-${cleaned.slice(4)}`;
};
const extractRawCode = (formatted: string): string => {
return formatted
.replace(/[^A-Za-z0-9]/g, '')
.toUpperCase()
.slice(0, CODE_LENGTH);
};
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 [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 showInstanceOption = IS_DEV || isDesktop();
const handleSubmit = React.useCallback(
async (rawCode: string) => {
if (!VALID_CODE_PATTERN.test(rawCode)) {
return;
}
setIsSubmitting(true);
setError(null);
try {
const customApiEndpoint = validatedInstance?.apiEndpoint;
const result = await AuthenticationActionCreators.pollDesktopHandoffStatus(rawCode, customApiEndpoint);
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;
}
if (result.status === 'pending') {
setError(i18n._(msg`This code hasn't been used yet. Please complete login in your browser first.`));
} else {
setError(i18n._(msg`Invalid or expired code. Please try again.`));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
setIsSubmitting(false);
}
},
[i18n, onSuccess, validatedInstance],
);
const handleCodeChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const rawCode = extractRawCode(e.target.value);
setCode(rawCode);
setError(null);
if (VALID_CODE_PATTERN.test(rawCode)) {
void handleSubmit(rawCode);
}
},
[handleSubmit],
);
const handleOpenBrowser = React.useCallback(async () => {
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
const baseUrl = validatedInstance?.webAppUrl || targetWebAppUrl || currentWebAppUrl;
const params = new URLSearchParams({desktop_handoff: '1'});
if (prefillEmail) {
params.set('email', prefillEmail);
}
const url = `${baseUrl}/login?${params.toString()}`;
await openExternalUrl(url);
}, [prefillEmail, targetWebAppUrl, validatedInstance]);
const handleShowInstanceView = React.useCallback(() => {
setView('instance');
}, []);
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}>
<p className={styles.description}>
<Trans>Log in using your browser, then enter the code shown to add the account.</Trans>
</p>
<div className={styles.codeInputSection}>
<Input
ref={inputRef}
label={i18n._(msg`Login code`)}
value={formatCodeForDisplay(code)}
onChange={handleCodeChange}
error={error ?? undefined}
disabled={isSubmitting}
autoComplete="off"
/>
</div>
{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>
</p>
) : null}
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={ModalActionCreators.pop} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button variant="primary" onClick={handleOpenBrowser} submitting={isSubmitting}>
<ArrowSquareOutIcon size={16} weight="bold" />
<Trans>Open browser</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
},
);
export function showBrowserLoginHandoffModal(
onSuccess: (payload: LoginSuccessPayload) => Promise<void>,
targetWebAppUrl?: string,
prefillEmail?: string,
): void {
ModalActionCreators.push(
modal(() => (
<BrowserLoginHandoffModal
onSuccess={async (payload) => {
await onSuccess(payload);
}}
targetWebAppUrl={targetWebAppUrl}
prefillEmail={prefillEmail}
/>
)),
);
}
export default BrowserLoginHandoffModal;

View File

@@ -0,0 +1,138 @@
/*
* 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/>.
*/
.fieldset {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.labelContainer {
display: flex;
align-items: center;
justify-content: space-between;
}
.legend {
font-weight: 500;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
}
.inputsContainer {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.fieldsRow {
display: flex;
gap: 0.5rem;
}
.monthField {
flex: 2 1 0%;
}
.dayField {
flex: 1.5 1 0%;
}
.yearField {
flex: 1.5 1 0%;
}
.errorText {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--status-danger);
}
@media (max-width: 720px) {
.fieldsRow {
flex-wrap: wrap;
}
.monthField,
.dayField,
.yearField {
flex: 1 1 calc(50% - 0.5rem);
min-width: 10rem;
}
.yearField {
flex-basis: 100%;
}
}
@media (max-width: 520px) {
.monthField,
.dayField,
.yearField {
flex: 1 1 100%;
min-width: 0;
}
}
.nativeDateInput {
width: 100%;
appearance: none;
-webkit-appearance: none;
border-radius: 0.5rem;
border: 1px solid var(--background-modifier-accent);
padding: 0.625rem 1rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
background-color: var(--form-surface-background);
min-height: 44px;
transition-property: color, background-color, border-color;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
:global(.theme-light) .nativeDateInput {
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);
}
.nativeDateInput::-webkit-date-and-time-value {
text-align: left;
}
.nativeDateInput::-webkit-calendar-picker-indicator {
opacity: 0.6;
cursor: pointer;
filter: var(--calendar-picker-filter, none);
}
@media (prefers-color-scheme: dark) {
.nativeDateInput::-webkit-calendar-picker-indicator {
filter: invert(1);
}
}

View File

@@ -0,0 +1,301 @@
/*
* 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, 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;
selectedYear: string;
onMonthChange: (month: string) => void;
onDayChange: (day: string) => void;
onYearChange: (year: string) => void;
error?: string;
}
interface NativeDatePickerProps {
selectedMonth: string;
selectedDay: string;
selectedYear: string;
onMonthChange: (month: string) => void;
onDayChange: (day: string) => void;
onYearChange: (year: string) => void;
error?: string;
}
function NativeDatePicker({
selectedMonth,
selectedDay,
selectedYear,
onMonthChange,
onDayChange,
onYearChange,
error,
}: NativeDatePickerProps) {
const {t} = useLingui();
const _monthPlaceholder = t`Month`;
const _dayPlaceholder = t`Day`;
const _yearPlaceholder = t`Year`;
const dateOfBirthPlaceholder = t`Date of birth`;
const currentYear = new Date().getFullYear();
const minDate = `${currentYear - 150}-01-01`;
const maxDate = `${currentYear}-12-31`;
const dateValue = useMemo(() => {
if (!selectedYear || !selectedMonth || !selectedDay) {
return '';
}
const year = selectedYear.padStart(4, '0');
const month = selectedMonth.padStart(2, '0');
const day = selectedDay.padStart(2, '0');
return `${year}-${month}-${day}`;
}, [selectedYear, selectedMonth, selectedDay]);
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (!value) {
onYearChange('');
onMonthChange('');
onDayChange('');
return;
}
const [year, month, day] = value.split('-');
onYearChange(String(parseInt(year, 10)));
onMonthChange(String(parseInt(month, 10)));
onDayChange(String(parseInt(day, 10)));
};
return (
<fieldset className={styles.fieldset}>
<div className={styles.labelContainer}>
<legend className={styles.legend}>
<Trans>Date of birth</Trans>
</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}
/>
{error && <span className={styles.errorText}>{error}</span>}
</div>
</fieldset>
);
}
export const DateOfBirthField = observer(function DateOfBirthField({
selectedMonth,
selectedDay,
selectedYear,
onMonthChange,
onDayChange,
onYearChange,
error,
}: DateOfBirthFieldProps) {
const {t} = useLingui();
const monthPlaceholder = t`Month`;
const dayPlaceholder = t`Day`;
const yearPlaceholder = t`Year`;
const locale = getCurrentLocale();
const fieldOrder = useMemo(() => getDateFieldOrder(locale), [locale]);
const dateOptions = useMemo(() => {
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
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,
};
});
const years = [];
for (let year = currentYear; year >= currentYear - 150; year--) {
years.push({
value: String(year),
label: String(year),
});
}
let availableDays = Array.from({length: 31}, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}));
if (selectedYear && selectedMonth) {
const year = Number(selectedYear);
const month = Number(selectedMonth);
const daysInMonth = new Date(year, month, 0).getDate();
availableDays = availableDays.filter((day) => Number(day.value) <= daysInMonth);
}
return {
months: allMonths,
days: availableDays,
years,
};
}, [selectedYear, selectedMonth, locale]);
if (isMobileWebBrowser()) {
return (
<NativeDatePicker
selectedMonth={selectedMonth}
selectedDay={selectedDay}
selectedYear={selectedYear}
onMonthChange={onMonthChange}
onDayChange={onDayChange}
onYearChange={onYearChange}
error={error}
/>
);
}
const handleYearChange = (year: string) => {
onYearChange(year);
if (selectedDay && selectedYear && selectedMonth) {
const daysInMonth = new Date(Number(year), Number(selectedMonth), 0).getDate();
if (Number(selectedDay) > daysInMonth) {
onDayChange('');
}
}
};
const handleMonthChange = (month: string) => {
onMonthChange(month);
if (selectedDay && selectedYear && month) {
const daysInMonth = new Date(Number(selectedYear), Number(month), 0).getDate();
if (Number(selectedDay) > daysInMonth) {
onDayChange('');
}
}
};
const fieldComponents: Record<DateFieldType, React.ReactElement> = {
month: (
<div key="month" className={styles.monthField}>
<Select
placeholder={monthPlaceholder}
options={dateOptions.months}
value={selectedMonth}
onChange={handleMonthChange}
tabIndex={0}
tabSelectsValue={false}
blurInputOnSelect={false}
openMenuOnFocus={true}
closeMenuOnSelect={true}
autoSelectExactMatch={true}
/>
</div>
),
day: (
<div key="day" className={styles.dayField}>
<Select
placeholder={dayPlaceholder}
options={dateOptions.days}
value={selectedDay}
onChange={onDayChange}
tabIndex={0}
tabSelectsValue={false}
blurInputOnSelect={false}
openMenuOnFocus={true}
closeMenuOnSelect={true}
autoSelectExactMatch={true}
/>
</div>
),
year: (
<div key="year" className={styles.yearField}>
<Select
placeholder={yearPlaceholder}
options={dateOptions.years}
value={selectedYear}
onChange={handleYearChange}
tabIndex={0}
tabSelectsValue={false}
blurInputOnSelect={false}
openMenuOnFocus={true}
closeMenuOnSelect={true}
autoSelectExactMatch={true}
/>
</div>
),
};
const orderedFields = fieldOrder.map((fieldType) => fieldComponents[fieldType]);
return (
<fieldset className={styles.fieldset}>
<div className={styles.labelContainer}>
<legend className={styles.legend}>
<Trans>Date of birth</Trans>
</legend>
</div>
<div className={styles.inputsContainer}>
<div className={styles.fieldsRow}>{orderedFields}</div>
{error && <span className={styles.errorText}>{error}</span>}
</div>
</fieldset>
);
});

View File

@@ -0,0 +1,61 @@
/*
* 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/>.
*/
.banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.9rem 1rem;
margin-bottom: 1rem;
border-radius: var(--radius-xl);
background: var(--background-secondary-alt);
border: 1px solid var(--border-color);
}
.copy {
flex: 1 1 0%;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.title {
margin: 0;
font-weight: 700;
color: var(--text-primary);
}
.body {
margin: 0;
color: var(--text-secondary);
font-size: 0.95rem;
}
.cta {
display: inline-flex;
gap: 0.4rem;
align-items: center;
white-space: nowrap;
}
.notInstalled {
color: var(--text-warning);
font-size: 0.875rem;
margin: 0;
}

View File

@@ -0,0 +1,107 @@
/*
* 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 {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;
kind: 'invite' | 'gift' | 'theme';
preferLogin?: boolean;
}
export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({code, kind, preferLogin = false}) => {
const [isLoading, setIsLoading] = useState(false);
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isDesktop()) return;
let cancelled = false;
checkDesktopAvailable().then(({available}) => {
if (!cancelled) {
setDesktopAvailable(available);
}
});
return () => {
cancelled = true;
};
}, []);
if (isDesktop()) return null;
if (desktopAvailable !== true) return null;
const getPath = (): string => {
switch (kind) {
case 'invite':
return preferLogin ? Routes.inviteLogin(code) : Routes.inviteRegister(code);
case 'gift':
return preferLogin ? Routes.giftLogin(code) : Routes.giftRegister(code);
case 'theme':
return preferLogin ? Routes.themeLogin(code) : Routes.themeRegister(code);
}
};
const path = getPath();
const handleOpen = async () => {
setIsLoading(true);
setError(null);
const result = await navigateInDesktop(path);
setIsLoading(false);
if (!result.success) {
setError(result.error ?? 'Failed to open in desktop app');
}
};
return (
<div className={styles.banner}>
<div className={styles.copy}>
<p className={styles.title}>
<Trans>Open in Fluxer for desktop</Trans>
</p>
{error ? (
<p className={styles.notInstalled}>{error}</p>
) : (
<p className={styles.body}>
<Trans>Jump straight to the app to continue.</Trans>
</p>
)}
</div>
<Button variant="primary" onClick={handleOpen} className={styles.cta} submitting={isLoading}>
<ArrowSquareOutIcon size={18} weight="fill" />
<span>
<Trans>Open Fluxer</Trans>
</span>
</Button>
</div>
);
};

View File

@@ -0,0 +1,121 @@
/*
* 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, 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';
interface DesktopHandoffAccountSelectorProps {
excludeCurrentUser?: boolean;
onSelectNewAccount: () => void;
}
const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSelector({
excludeCurrentUser = false,
onSelectNewAccount,
}: DesktopHandoffAccountSelectorProps) {
const {t} = useLingui();
const [handoffState, setHandoffState] = useState<HandoffState>('selecting');
const [handoffCode, setHandoffCode] = useState<string | null>(null);
const [handoffError, setHandoffError] = useState<string | null>(null);
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null);
const currentUserId = AccountManager.currentUserId;
const allAccounts = AccountManager.orderedAccounts;
const accounts = excludeCurrentUser ? allAccounts.filter((account) => account.userId !== currentUserId) : allAccounts;
const isGenerating = handoffState === 'generating';
const handleSelectAccount = useCallback(async (account: AccountSummary) => {
setSelectedAccountId(account.userId);
setHandoffState('generating');
setHandoffError(null);
try {
const {token, userId} = await AccountManager.generateTokenForAccount(account.userId);
if (!token) {
throw new Error('Failed to generate token');
}
const result = await AuthenticationActionCreators.initiateDesktopHandoff();
await AuthenticationActionCreators.completeDesktopHandoff({
code: result.code,
token,
userId,
});
setHandoffCode(result.code);
setHandoffState('displaying');
} catch (error) {
setHandoffState('error');
if (error instanceof SessionExpiredError) {
setHandoffError(t`Session expired. Please log in again.`);
} else {
setHandoffError(error instanceof Error ? error.message : t`Failed to generate handoff code`);
}
}
}, []);
const handleRetry = useCallback(() => {
if (selectedAccountId) {
const account = allAccounts.find((a) => a.userId === selectedAccountId);
if (account) {
void handleSelectAccount(account);
return;
}
}
setHandoffState('selecting');
setSelectedAccountId(null);
setHandoffError(null);
}, [selectedAccountId, allAccounts, handleSelectAccount]);
if (handoffState === 'generating' || handoffState === 'displaying' || handoffState === 'error') {
return (
<HandoffCodeDisplay
code={handoffCode}
isGenerating={handoffState === 'generating'}
error={handoffState === 'error' ? handoffError : null}
onRetry={handleRetry}
/>
);
}
return (
<AccountSelector
accounts={accounts}
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
clickableRows
onSelectAccount={handleSelectAccount}
onAddAccount={onSelectNewAccount}
addButtonLabel={<Trans>Add a different account</Trans>}
scrollerKey="desktop-handoff-scroller"
/>
);
});
export default DesktopHandoffAccountSelector;

View File

@@ -0,0 +1,45 @@
/*
* 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 {observer} from 'mobx-react-lite';
import {Input} from '~/components/form/Input';
interface FormFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> {
name: string;
label?: React.ReactNode;
value: string;
error?: string;
placeholder?: string;
onChange: (value: string) => void;
}
const FormField = observer(function FormField({name, label, value, error, onChange, ...props}: FormFieldProps) {
return (
<Input
name={name}
label={typeof label === 'string' ? label : undefined}
value={value}
error={error}
onChange={(e) => onChange(e.target.value)}
{...props}
/>
);
});
export default FormField;

View File

@@ -0,0 +1,60 @@
/*
* 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, 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;
variant: 'login' | 'register';
}
export function GiftHeader({gift, variant}: GiftHeaderProps) {
const {i18n} = useLingui();
const durationText = getPremiumGiftDurationText(i18n, gift);
const sender =
gift.created_by?.username && gift.created_by.discriminator
? `${gift.created_by.username}#${gift.created_by.discriminator}`
: null;
return (
<div className={styles.entityHeader}>
<div className={styles.giftIconContainer}>
<GiftIcon className={styles.giftIcon} />
</div>
<div className={styles.entityDetails}>
<p className={styles.entityText}>
{sender ? <Trans>{sender} sent you a gift!</Trans> : <Trans>You've received a gift!</Trans>}
</p>
<h2 className={styles.entityTitle}>{durationText}</h2>
<p className={styles.entitySubtext}>
{variant === 'login' ? (
<Trans>Log in to claim your gift</Trans>
) : (
<Trans>Create an account to claim your gift</Trans>
)}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
/*
* 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 {
text-align: center;
}
.title {
margin-bottom: 0.5rem;
text-align: center;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.025em;
color: var(--text-primary);
}
.description {
margin-bottom: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: var(--text-tertiary);
}
.codeSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin: 1.5rem 0;
}
.codeLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.codeDisplay {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
background-color: var(--background-tertiary);
border: 2px solid var(--background-modifier-accent);
border-radius: 12px;
}
.codeChar {
font-family: var(--font-mono);
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.1em;
}
.codeSeparator {
font-family: var(--font-mono);
font-size: 2rem;
font-weight: 700;
color: var(--text-tertiary);
margin: 0 4px;
}
.spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 1.5rem 0;
}
.spinnerIcon {
width: 24px;
height: 24px;
border: 3px solid var(--background-modifier-accent);
border-top-color: var(--brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.error {
background-color: hsla(0, calc(100% * var(--saturation-factor)), 50%, 0.1);
border: 1px solid hsla(0, calc(100% * var(--saturation-factor)), 50%, 0.2);
border-radius: 8px;
padding: 12px;
font-size: 0.875rem;
color: var(--status-danger);
margin: 1rem 0;
text-align: center;
}

View File

@@ -0,0 +1,111 @@
/*
* 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 {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;
isGenerating: boolean;
error: string | null;
onRetry?: () => void;
}
export function HandoffCodeDisplay({code, isGenerating, error, onRetry}: HandoffCodeDisplayProps) {
const [copied, setCopied] = useState(false);
const handleCopyCode = useCallback(async () => {
if (!code) return;
await TextCopyActionCreators.copy(i18n, code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [code]);
if (isGenerating) {
return (
<div className={styles.container}>
<h1 className={styles.title}>
<Trans>Generating code...</Trans>
</h1>
<div className={styles.spinner}>
<span className={styles.spinnerIcon} />
</div>
</div>
);
}
if (error) {
return (
<div className={styles.container}>
<h1 className={styles.title}>
<Trans>Something went wrong</Trans>
</h1>
<p className={styles.error}>{error}</p>
{onRetry && (
<Button onClick={onRetry} fitContainer>
<Trans>Try again</Trans>
</Button>
)}
</div>
);
}
if (!code) {
return null;
}
const codeWithoutHyphen = code.replace(/-/g, '');
const codePart1 = codeWithoutHyphen.slice(0, 4);
const codePart2 = codeWithoutHyphen.slice(4, 8);
return (
<div className={styles.container}>
<h1 className={styles.title}>
<Trans>Your code is ready!</Trans>
</h1>
<p className={styles.description}>
<Trans>Paste it where you came from to complete sign-in.</Trans>
</p>
<div className={styles.codeSection}>
<p className={styles.codeLabel}>
<Trans>Your code</Trans>
</p>
<div className={styles.codeDisplay}>
<span className={styles.codeChar}>{codePart1}</span>
<span className={styles.codeSeparator}>-</span>
<span className={styles.codeChar}>{codePart2}</span>
</div>
<Button
type="button"
onClick={handleCopyCode}
leftIcon={copied ? <CheckCircleIcon size={16} weight="bold" /> : <ClipboardIcon size={16} />}
variant="secondary"
>
{copied ? <Trans>Copied!</Trans> : <Trans>Copy code</Trans>}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
/*
* 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, 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';
interface InviteHeaderProps {
invite: Invite;
}
interface GuildInviteHeaderProps {
invite: GuildInvite;
}
interface GroupDMInviteHeaderProps {
invite: GroupDmInvite;
}
interface PackInviteHeaderProps {
invite: PackInvite;
}
interface PreviewGuildInviteHeaderProps {
guildId: string;
guildName: string;
guildIcon: string | null;
isVerified: boolean;
presenceCount: number;
memberCount: number;
previewIconUrl?: string | null;
previewName?: string | null;
}
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 memberCount = invite.member_count ?? 0;
return (
<div className={styles.entityHeader}>
<div className={styles.entityIconWrapper}>
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} className={styles.entityIcon} sizePx={80} />
</div>
<div className={styles.entityDetails}>
<p className={styles.entityText}>
<Trans>You've been invited to join</Trans>
</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}
</div>
<div className={styles.entityStats}>
<div className={styles.entityStat}>
<div className={styles.onlineDot} />
<span className={styles.statText}>
<Trans>{invite.presence_count} Online</Trans>
</span>
</div>
<div className={styles.entityStat}>
<div className={styles.offlineDot} />
<span className={styles.statText}>
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
</span>
</div>
</div>
</div>
</div>
);
});
export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite}: GroupDMInviteHeaderProps) {
const {t} = useLingui();
const inviter = invite.inviter;
const avatarUrl = inviter ? AvatarUtils.getUserAvatarURL(inviter, false) : null;
const memberCount = invite.member_count ?? 0;
return (
<div className={styles.entityHeader}>
{inviter && avatarUrl ? <BaseAvatar size={80} avatarUrl={avatarUrl} shouldPlayAnimated={false} /> : null}
<div className={styles.entityDetails}>
<p className={styles.entityText}>
<Trans>You've been invited to join a group DM by</Trans>
</p>
{inviter ? (
<h2 className={styles.entityTitle}>
{inviter.username}#{inviter.discriminator}
</h2>
) : null}
<div className={styles.entityStats}>
<div className={styles.entityStat}>
<div className={styles.offlineDot} />
<span className={styles.statText}>
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
</span>
</div>
</div>
</div>
</div>
);
});
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 packKindLabel = pack.type === 'emoji' ? t`Emoji pack` : t`Sticker pack`;
const inviterTag = invite.inviter ? `${invite.inviter.username}#${invite.inviter.discriminator}` : null;
return (
<div className={styles.entityHeader}>
<div className={styles.entityIconWrapper}>
<Avatar user={creatorRecord} size={80} className={styles.entityIcon} />
</div>
<div className={styles.entityDetails}>
<p className={styles.entityText}>
<Trans>You've been invited to install</Trans>
</p>
<div className={styles.entityTitleWrapper}>
<h2 className={styles.entityTitle}>{pack.name}</h2>
<span className={styles.packBadge}>{packKindLabel}</span>
</div>
<p className={styles.packDescription}>{pack.description || t`No description provided.`}</p>
<div className={styles.packMeta}>
<span className={styles.packMetaText}>{t`Created by ${pack.creator.username}`}</span>
{inviterTag ? <span className={styles.packMetaText}>{t`Invited by ${inviterTag}`}</span> : null}
</div>
</div>
</div>
);
});
export function InviteHeader({invite}: InviteHeaderProps) {
if (isGroupDmInvite(invite)) {
return <GroupDMInviteHeader invite={invite} />;
}
if (isPackInvite(invite)) {
return <PackInviteHeader invite={invite} />;
}
if (isGuildInvite(invite)) {
return <GuildInviteHeader invite={invite} />;
}
return null;
}
export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHeader({
guildId,
guildName,
guildIcon,
isVerified,
presenceCount,
memberCount,
previewIconUrl,
previewName,
}: PreviewGuildInviteHeaderProps) {
const {t} = useLingui();
const displayName = previewName ?? guildName;
const [hasPreviewIconError, setPreviewIconError] = React.useState(false);
React.useEffect(() => {
setPreviewIconError(false);
}, [previewIconUrl]);
const shouldShowPreviewIcon = Boolean(previewIconUrl && !hasPreviewIconError);
return (
<div className={styles.entityHeader}>
<div className={styles.entityIconWrapper}>
{shouldShowPreviewIcon ? (
<img
src={previewIconUrl as string}
alt=""
className={styles.entityIcon}
onError={(e) => {
e.currentTarget.style.display = 'none';
setPreviewIconError(true);
}}
/>
) : (
<GuildIcon id={guildId} name={displayName} icon={guildIcon} className={styles.entityIcon} sizePx={80} />
)}
</div>
<div className={styles.entityDetails}>
<p className={styles.entityText}>
<Trans>You've been invited to join</Trans>
</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}
</div>
<div className={styles.entityStats}>
<div className={styles.entityStat}>
<div className={styles.onlineDot} />
<span className={styles.statText}>
<Trans>{presenceCount} Online</Trans>
</span>
</div>
<div className={styles.entityStat}>
<div className={styles.offlineDot} />
<span className={styles.statText}>
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
</span>
</div>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,66 @@
/*
* 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;
align-items: center;
gap: 1rem;
padding: 2rem 1.5rem;
text-align: center;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--background-modifier-accent);
color: var(--text-primary);
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.025em;
color: var(--text-primary);
}
.description {
font-size: 0.9375rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.retryingText {
font-size: 0.875rem;
color: var(--text-muted);
margin: 0;
}
.actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}

View File

@@ -0,0 +1,179 @@
/*
* 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 {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';
interface IpAuthorizationScreenProps {
challenge: IpAuthorizationChallenge;
onAuthorized: (payload: {token: string; userId: string}) => Promise<void> | void;
onBack?: () => void;
}
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 2000;
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 onAuthorizedRef = useRef(onAuthorized);
onAuthorizedRef.current = onAuthorized;
useEffect(() => {
setResendUsed(false);
setResendIn(challenge.resendAvailableIn);
setConnectionState('connecting');
setRetryCount(0);
}, [challenge]);
useEffect(() => {
let es: EventSource | null = null;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
let isMounted = true;
const connect = () => {
if (!isMounted) return;
es = AuthenticationActionCreators.subscribeToIpAuthorization(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;
});
};
};
connect();
return () => {
isMounted = false;
es?.close();
if (retryTimeout) {
clearTimeout(retryTimeout);
}
};
}, [challenge.ticket]);
useEffect(() => {
if (resendIn <= 0) return;
const interval = setInterval(() => {
setResendIn((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
return () => clearInterval(interval);
}, [resendIn]);
const handleResend = useCallback(async () => {
if (resendIn > 0 || resendUsed) return;
try {
await AuthenticationActionCreators.resendIpAuthorization(challenge.ticket);
setResendUsed(true);
setResendIn(30);
} catch (error) {
console.error('Failed to resend IP authorization email', error);
}
}, [challenge.ticket, resendIn, resendUsed]);
const handleRetryConnection = useCallback(() => {
setRetryCount(0);
setConnectionState('connecting');
}, []);
return (
<div className={styles.container}>
<div className={styles.icon}>
{connectionState === '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>}
</h1>
<p className={styles.description}>
{connectionState === '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}>
<Trans>Retry</Trans>
</Button>
) : (
<Button variant="secondary" onClick={handleResend} disabled={resendIn > 0 || resendUsed}>
{resendUsed ? <Trans>Resent</Trans> : <Trans>Resend email</Trans>}
{resendIn > 0 ? ` (${resendIn}s)` : ''}
</Button>
)}
{onBack ? (
<Button variant="secondary" onClick={onBack}>
<Trans>Back</Trans>
</Button>
) : null}
</div>
</div>
);
};
export default IpAuthorizationScreen;

View File

@@ -0,0 +1,88 @@
/*
* 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;
}
.title {
margin-bottom: 0.5rem;
text-align: center;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.025em;
color: var(--text-primary);
}
.description {
margin-bottom: 2rem;
text-align: center;
font-size: 0.875rem;
color: var(--text-tertiary);
}
.buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.smsSection {
margin-bottom: 1.5rem;
}
.webauthnSection {
margin-bottom: 1rem;
}
.footer {
margin-top: 1.5rem;
text-align: center;
}
.footerButtons {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.footerButton {
display: block;
width: 100%;
background: none;
border: none;
padding: 0;
font-size: 0.875rem;
color: var(--text-tertiary);
cursor: pointer;
}
.footerButton:hover,
.footerButton:focus {
color: var(--text-primary);
}

View File

@@ -0,0 +1,158 @@
/*
* 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, 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;
inviteCode?: string;
onSuccess: (payload: LoginSuccessPayload) => Promise<void> | void;
onCancel: () => void;
}
const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps) => {
const {t} = useLingui();
const codeId = useId();
const {
form,
isLoading,
fieldErrors,
selectedMethod,
setSelectedMethod,
smsSent,
handleSendSms,
handleWebAuthn,
isWebAuthnLoading,
supports,
} = useMfaController({
ticket: challenge.ticket,
methods: {sms: challenge.sms, totp: challenge.totp, webauthn: challenge.webauthn},
inviteCode,
onLoginSuccess: onSuccess,
});
if (!selectedMethod && (supports.sms || supports.webauthn || supports.totp)) {
return (
<div className={styles.container}>
<h1 className={styles.title}>
<Trans>Two-factor authentication</Trans>
</h1>
<p className={styles.description}>
<Trans>Choose a verification method</Trans>
</p>
<div className={styles.buttons}>
{supports.totp && (
<Button type="button" fitContainer onClick={() => setSelectedMethod('totp')}>
<Trans>Authenticator App</Trans>
</Button>
)}
{supports.sms && (
<Button type="button" fitContainer variant="secondary" onClick={() => setSelectedMethod('sms')}>
<Trans>SMS Code</Trans>
</Button>
)}
{supports.webauthn && (
<Button
type="button"
fitContainer
variant="secondary"
onClick={handleWebAuthn}
disabled={isWebAuthnLoading}
>
<Trans>Security Key / Passkey</Trans>
</Button>
)}
</div>
<div className={styles.footer}>
<Button type="button" variant="secondary" onClick={onCancel} className={styles.footerButton}>
<Trans>Back to login</Trans>
</Button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<h1 className={styles.title}>
<Trans>Two-factor authentication</Trans>
</h1>
<p className={styles.description}>
{selectedMethod === 'sms' ? (
<Trans>Enter the 6-digit code sent to your phone.</Trans>
) : (
<Trans>Enter the 6-digit code from your authenticator app or one of your backup codes.</Trans>
)}
</p>
{selectedMethod === 'sms' && !smsSent && supports.sms && (
<div className={styles.smsSection}>
<Button type="button" fitContainer onClick={handleSendSms}>
<Trans>Send SMS Code</Trans>
</Button>
</div>
)}
{supports.webauthn && (
<div className={styles.webauthnSection}>
<Button type="button" fitContainer variant="secondary" onClick={handleWebAuthn} disabled={isWebAuthnLoading}>
<Trans>Try security key / passkey instead</Trans>
</Button>
</div>
)}
<form className={styles.form} onSubmit={form.handleSubmit}>
<FormField
id={codeId}
name="code"
type="text"
autoComplete="one-time-code"
required
label={t`Code`}
value={form.getValue('code')}
onChange={(value) => form.setValue('code', value)}
error={form.getError('code') || fieldErrors?.code}
/>
<Button type="submit" fitContainer disabled={isLoading || form.isSubmitting}>
<Trans>Log in</Trans>
</Button>
</form>
<div className={styles.footerButtons}>
{(supports.sms || supports.webauthn || supports.totp) && (
<Button
type="button"
variant="secondary"
onClick={() => setSelectedMethod(null)}
className={styles.footerButton}
>
<Trans>Try another method</Trans>
</Button>
)}
<Button type="button" variant="secondary" onClick={onCancel} className={styles.footerButton}>
<Trans>Back to login</Trans>
</Button>
</div>
</div>
);
};
export default MfaScreen;

View File

@@ -0,0 +1,135 @@
/*
* 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, 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;
}
export function MockMinimalRegisterForm({submitLabel}: MockMinimalRegisterFormProps) {
const {t} = useLingui();
const locale = getCurrentLocale();
const fieldOrder = useMemo(() => getDateFieldOrder(locale), [locale]);
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} />
</div>
),
day: (
<div key="day" className={dobStyles.dayField}>
<input type="text" 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} />
</div>
),
};
const orderedFields = fieldOrder.map((fieldType) => dateFields[fieldType]);
return (
<div className={authStyles.form}>
<div className={inputStyles.fieldset}>
<div className={inputStyles.labelContainer}>
<span className={inputStyles.label}>
<Trans>Display name (optional)</Trans>
</span>
</div>
<div className={inputStyles.inputGroup}>
<input
type="text"
readOnly
tabIndex={-1}
placeholder={t`What should people call you?`}
className={inputStyles.input}
/>
</div>
</div>
<div className={dobStyles.fieldset}>
<div className={dobStyles.labelContainer}>
<span className={dobStyles.legend}>
<Trans>Date of birth</Trans>
</span>
</div>
<div className={dobStyles.inputsContainer}>
<div className={dobStyles.fieldsRow}>{orderedFields}</div>
</div>
</div>
<div className={authStyles.consentRow}>
<Checkbox checked={false} onChange={() => {}} disabled>
<span className={authStyles.consentLabel}>
<Trans>I agree to the</Trans>{' '}
<ExternalLink href={Routes.terms()} className={authStyles.policyLink}>
<Trans>Terms of Service</Trans>
</ExternalLink>{' '}
<Trans>and</Trans>{' '}
<ExternalLink href={Routes.privacy()} className={authStyles.policyLink}>
<Trans>Privacy Policy</Trans>
</ExternalLink>
</span>
</Checkbox>
</div>
<Button type="button" fitContainer disabled>
{submitLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,22 @@
/*
* 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/>.
*/
.buttonWrapper {
width: 100%;
}

View File

@@ -0,0 +1,73 @@
/*
* 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 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;
label: string;
}
export interface SubmitTooltipProps {
children: ReactNode;
consent: boolean;
missingFields?: Array<MissingField>;
}
const CONSENT_REQUIRED_DESCRIPTOR = msg`You must agree to the Terms of Service and Privacy Policy to create an account`;
const getMissingFieldsDescriptor = (fieldList: string): MessageDescriptor =>
msg`Please fill out the following fields: ${fieldList}`;
function getTooltipContentDescriptor(consent: boolean, missingFields: Array<MissingField>): MessageDescriptor | null {
if (!consent) {
return CONSENT_REQUIRED_DESCRIPTOR;
}
if (missingFields.length > 0) {
const fieldList = missingFields.map((f) => f.label).join(', ');
return getMissingFieldsDescriptor(fieldList);
}
return null;
}
export function shouldDisableSubmit(consent: boolean, missingFields: Array<MissingField>): boolean {
return !consent || missingFields.length > 0;
}
export function SubmitTooltip({children, consent, missingFields = []}: SubmitTooltipProps) {
const {t} = useLingui();
const tooltipContentDescriptor = getTooltipContentDescriptor(consent, missingFields);
const tooltipContent = tooltipContentDescriptor ? t(tooltipContentDescriptor) : null;
if (!tooltipContent) {
return <>{children}</>;
}
return (
<Tooltip text={tooltipContent} position="top">
<div className={styles.buttonWrapper}>{children}</div>
</Tooltip>
);
}

View File

@@ -0,0 +1,84 @@
/*
* 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

@@ -0,0 +1,59 @@
/*
* 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>
);
});