initial commit
This commit is contained in:
138
fluxer_app/src/components/auth/AuthBackground.tsx
Normal file
138
fluxer_app/src/components/auth/AuthBackground.tsx
Normal 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;
|
||||
};
|
||||
48
fluxer_app/src/components/auth/AuthBottomLink.tsx
Normal file
48
fluxer_app/src/components/auth/AuthBottomLink.tsx
Normal 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>;
|
||||
}
|
||||
36
fluxer_app/src/components/auth/AuthCardContainer.module.css
Normal file
36
fluxer_app/src/components/auth/AuthCardContainer.module.css
Normal 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;
|
||||
}
|
||||
50
fluxer_app/src/components/auth/AuthCardContainer.tsx
Normal file
50
fluxer_app/src/components/auth/AuthCardContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
fluxer_app/src/components/auth/AuthErrorState.tsx
Normal file
40
fluxer_app/src/components/auth/AuthErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
fluxer_app/src/components/auth/AuthLoadingState.tsx
Normal file
29
fluxer_app/src/components/auth/AuthLoadingState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
285
fluxer_app/src/components/auth/AuthLoginLayout.tsx
Normal file
285
fluxer_app/src/components/auth/AuthLoginLayout.tsx
Normal 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};
|
||||
151
fluxer_app/src/components/auth/AuthMinimalRegisterFormCore.tsx
Normal file
151
fluxer_app/src/components/auth/AuthMinimalRegisterFormCore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
fluxer_app/src/components/auth/AuthPageHeader.tsx
Normal file
68
fluxer_app/src/components/auth/AuthPageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
429
fluxer_app/src/components/auth/AuthPageStyles.module.css
Normal file
429
fluxer_app/src/components/auth/AuthPageStyles.module.css
Normal 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;
|
||||
}
|
||||
285
fluxer_app/src/components/auth/AuthRegisterFormCore.tsx
Normal file
285
fluxer_app/src/components/auth/AuthRegisterFormCore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
fluxer_app/src/components/auth/AuthRouterLink.tsx
Normal file
40
fluxer_app/src/components/auth/AuthRouterLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
352
fluxer_app/src/components/auth/BrowserLoginHandoffModal.tsx
Normal file
352
fluxer_app/src/components/auth/BrowserLoginHandoffModal.tsx
Normal 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;
|
||||
138
fluxer_app/src/components/auth/DateOfBirthField.module.css
Normal file
138
fluxer_app/src/components/auth/DateOfBirthField.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
301
fluxer_app/src/components/auth/DateOfBirthField.tsx
Normal file
301
fluxer_app/src/components/auth/DateOfBirthField.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
107
fluxer_app/src/components/auth/DesktopDeepLinkPrompt.tsx
Normal file
107
fluxer_app/src/components/auth/DesktopDeepLinkPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
121
fluxer_app/src/components/auth/DesktopHandoffAccountSelector.tsx
Normal file
121
fluxer_app/src/components/auth/DesktopHandoffAccountSelector.tsx
Normal 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;
|
||||
45
fluxer_app/src/components/auth/FormField.tsx
Normal file
45
fluxer_app/src/components/auth/FormField.tsx
Normal 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;
|
||||
60
fluxer_app/src/components/auth/GiftHeader.tsx
Normal file
60
fluxer_app/src/components/auth/GiftHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
fluxer_app/src/components/auth/HandoffCodeDisplay.module.css
Normal file
118
fluxer_app/src/components/auth/HandoffCodeDisplay.module.css
Normal 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;
|
||||
}
|
||||
111
fluxer_app/src/components/auth/HandoffCodeDisplay.tsx
Normal file
111
fluxer_app/src/components/auth/HandoffCodeDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
fluxer_app/src/components/auth/InviteHeader.tsx
Normal file
248
fluxer_app/src/components/auth/InviteHeader.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
179
fluxer_app/src/components/auth/IpAuthorizationScreen.tsx
Normal file
179
fluxer_app/src/components/auth/IpAuthorizationScreen.tsx
Normal 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;
|
||||
88
fluxer_app/src/components/auth/MfaScreen.module.css
Normal file
88
fluxer_app/src/components/auth/MfaScreen.module.css
Normal 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);
|
||||
}
|
||||
158
fluxer_app/src/components/auth/MfaScreen.tsx
Normal file
158
fluxer_app/src/components/auth/MfaScreen.tsx
Normal 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;
|
||||
135
fluxer_app/src/components/auth/MockMinimalRegisterForm.tsx
Normal file
135
fluxer_app/src/components/auth/MockMinimalRegisterForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
fluxer_app/src/components/auth/SubmitTooltip.module.css
Normal file
22
fluxer_app/src/components/auth/SubmitTooltip.module.css
Normal 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%;
|
||||
}
|
||||
73
fluxer_app/src/components/auth/SubmitTooltip.tsx
Normal file
73
fluxer_app/src/components/auth/SubmitTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
59
fluxer_app/src/components/auth/UsernameSuggestions.tsx
Normal file
59
fluxer_app/src/components/auth/UsernameSuggestions.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user