refactor progress
This commit is contained in:
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/layout/AuthLayout.module.css';
|
||||
import AccessibilityStore from '@app/stores/AccessibilityStore';
|
||||
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
import {motion} from 'framer-motion';
|
||||
import type React from 'react';
|
||||
import {GuildSplashCardAlignment} from '~/Constants';
|
||||
import styles from '~/components/layout/AuthLayout.module.css';
|
||||
|
||||
const getSplashAlignmentStyles = (
|
||||
alignment: (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment],
|
||||
) => {
|
||||
const getSplashAlignmentStyles = (alignment: ValueOf<typeof GuildSplashCardAlignment>) => {
|
||||
switch (alignment) {
|
||||
case GuildSplashCardAlignment.LEFT:
|
||||
return {transformOrigin: 'bottom left', objectPosition: 'left bottom'};
|
||||
@@ -44,7 +44,7 @@ export interface AuthBackgroundProps {
|
||||
patternImageUrl: string;
|
||||
className?: string;
|
||||
useFullCover?: boolean;
|
||||
splashAlignment?: (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment];
|
||||
splashAlignment?: ValueOf<typeof GuildSplashCardAlignment>;
|
||||
}
|
||||
|
||||
export const AuthBackground: React.FC<AuthBackgroundProps> = ({
|
||||
@@ -68,7 +68,7 @@ export const AuthBackground: React.FC<AuthBackgroundProps> = ({
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: splashLoaded ? 1 : 0}}
|
||||
transition={{duration: 0.5, ease: 'easeInOut'}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.5, ease: 'easeInOut'}}
|
||||
style={{position: 'absolute', inset: 0}}
|
||||
>
|
||||
<img
|
||||
@@ -95,7 +95,7 @@ export const AuthBackground: React.FC<AuthBackgroundProps> = ({
|
||||
className={styles.splashImage}
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: splashLoaded ? 1 : 0}}
|
||||
transition={{duration: 0.5, ease: 'easeInOut'}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.5, ease: 'easeInOut'}}
|
||||
style={{
|
||||
width: splashDimensions.width,
|
||||
height: splashDimensions.height,
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {AuthRouterLink} from '@app/components/auth/AuthRouterLink';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {AuthRouterLink} from './AuthRouterLink';
|
||||
|
||||
interface AuthBottomLinkProps {
|
||||
variant: 'login' | 'register';
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/AuthCardContainer.module.css';
|
||||
import authLayoutStyles from '@app/components/layout/AuthLayout.module.css';
|
||||
import FluxerLogo from '@app/images/fluxer-logo-color.svg?react';
|
||||
import FluxerWordmark from '@app/images/fluxer-wordmark.svg?react';
|
||||
import clsx from 'clsx';
|
||||
import type {ReactNode} from 'react';
|
||||
import authLayoutStyles from '~/components/layout/AuthLayout.module.css';
|
||||
import FluxerLogo from '~/images/fluxer-logo-color.svg?react';
|
||||
import FluxerWordmark from '~/images/fluxer-wordmark.svg?react';
|
||||
import styles from './AuthCardContainer.module.css';
|
||||
|
||||
export interface AuthCardContainerProps {
|
||||
interface AuthCardContainerProps {
|
||||
showLogoSide?: boolean;
|
||||
children: ReactNode;
|
||||
isInert?: boolean;
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import type {Icon} from '@phosphor-icons/react';
|
||||
import {QuestionIcon} from '@phosphor-icons/react';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
interface AuthErrorStateProps {
|
||||
icon?: Icon;
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {Spinner} from '@app/components/uikit/Spinner';
|
||||
import type {JSX} from 'react';
|
||||
|
||||
export function AuthLoadingState() {
|
||||
export function AuthLoadingState(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner />
|
||||
|
||||
@@ -17,26 +17,29 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import {AccountSelector} from '@app/components/accounts/AccountSelector';
|
||||
import {AuthRouterLink} from '@app/components/auth/AuthRouterLink';
|
||||
import AuthLoginEmailPasswordForm from '@app/components/auth/auth_login_core/AuthLoginEmailPasswordForm';
|
||||
import AuthLoginPasskeyActions, {AuthLoginDivider} from '@app/components/auth/auth_login_core/AuthLoginPasskeyActions';
|
||||
import {useDesktopHandoffFlow} from '@app/components/auth/auth_login_core/useDesktopHandoffFlow';
|
||||
import DesktopHandoffAccountSelector from '@app/components/auth/DesktopHandoffAccountSelector';
|
||||
import {HandoffCodeDisplay} from '@app/components/auth/HandoffCodeDisplay';
|
||||
import IpAuthorizationScreen from '@app/components/auth/IpAuthorizationScreen';
|
||||
import styles from '@app/components/pages/LoginPage.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {useLoginFormController} from '@app/hooks/useLoginFlow';
|
||||
import {IS_DEV} from '@app/lib/Env';
|
||||
import {type Account, SessionExpiredError} from '@app/lib/SessionManager';
|
||||
import AccountManager from '@app/stores/AccountManager';
|
||||
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
|
||||
import {isDesktop} from '@app/utils/NativeUtils';
|
||||
import * as RouterUtils from '@app/utils/RouterUtils';
|
||||
import {type IpAuthorizationChallenge, type LoginSuccessPayload, startSsoLogin} from '@app/viewmodels/auth/AuthFlow';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {cloneElement, type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {AccountSelector} from '~/components/accounts/AccountSelector';
|
||||
import AuthLoginEmailPasswordForm from '~/components/auth/AuthLoginCore/AuthLoginEmailPasswordForm';
|
||||
import AuthLoginPasskeyActions, {AuthLoginDivider} from '~/components/auth/AuthLoginCore/AuthLoginPasskeyActions';
|
||||
import {useDesktopHandoffFlow} from '~/components/auth/AuthLoginCore/useDesktopHandoffFlow';
|
||||
import {AuthRouterLink} from '~/components/auth/AuthRouterLink';
|
||||
import DesktopHandoffAccountSelector from '~/components/auth/DesktopHandoffAccountSelector';
|
||||
import {HandoffCodeDisplay} from '~/components/auth/HandoffCodeDisplay';
|
||||
import IpAuthorizationScreen from '~/components/auth/IpAuthorizationScreen';
|
||||
import styles from '~/components/pages/LoginPage.module.css';
|
||||
import {type IpAuthorizationChallenge, type LoginSuccessPayload, useLoginFormController} from '~/hooks/useLoginFlow';
|
||||
import {IS_DEV} from '~/lib/env';
|
||||
import {SessionExpiredError} from '~/lib/SessionManager';
|
||||
import AccountManager, {type AccountSummary} from '~/stores/AccountManager';
|
||||
import {isDesktop} from '~/utils/NativeUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
|
||||
interface AuthLoginLayoutProps {
|
||||
redirectPath?: string;
|
||||
@@ -51,7 +54,7 @@ interface AuthLoginLayoutProps {
|
||||
initialEmail?: string;
|
||||
}
|
||||
|
||||
const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
desktopHandoff = false,
|
||||
@@ -67,6 +70,10 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
const currentUserId = AccountManager.currentUserId;
|
||||
const accounts = AccountManager.orderedAccounts;
|
||||
const hasStoredAccounts = accounts.length > 0;
|
||||
const ssoConfig = RuntimeConfigStore.sso;
|
||||
const isSsoEnforced = Boolean(ssoConfig?.enforced);
|
||||
const ssoDisplayName = ssoConfig?.display_name ?? 'Single Sign-On';
|
||||
const [isStartingSso, setIsStartingSso] = useState(false);
|
||||
|
||||
const handoffAccounts =
|
||||
desktopHandoff && excludeCurrentUser ? accounts.filter((a) => a.userId !== currentUserId) : accounts;
|
||||
@@ -84,7 +91,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
const [switchError, setSwitchError] = useState<string | null>(null);
|
||||
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
|
||||
|
||||
const showLoginFormForAccount = useCallback((account: AccountSummary, message?: string | null) => {
|
||||
const showLoginFormForAccount = useCallback((account: Account, message?: string | null) => {
|
||||
setShowAccountSelector(false);
|
||||
setSwitchError(message ?? null);
|
||||
setPrefillEmail(account.userData?.email ?? null);
|
||||
@@ -143,7 +150,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
}, [form, prefillEmail]);
|
||||
|
||||
const handleSelectExistingAccount = useCallback(
|
||||
async (account: AccountSummary) => {
|
||||
async (account: Account) => {
|
||||
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
|
||||
const expiredMessage = t`Session expired for ${identifier}. Please log in again.`;
|
||||
|
||||
@@ -177,6 +184,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
setPrefillEmail(null);
|
||||
}, []);
|
||||
|
||||
const handleStartSso = useCallback(async () => {
|
||||
if (!ssoConfig?.enabled) return;
|
||||
try {
|
||||
setIsStartingSso(true);
|
||||
const {authorizationUrl} = await startSsoLogin({
|
||||
redirectTo: redirectPath,
|
||||
});
|
||||
window.location.assign(authorizationUrl);
|
||||
} catch (error) {
|
||||
setSwitchError(error instanceof Error ? error.message : t`Failed to start SSO`);
|
||||
} finally {
|
||||
setIsStartingSso(false);
|
||||
}
|
||||
}, [ssoConfig?.enabled, redirectPath, t]);
|
||||
|
||||
const styledRegisterLink = useMemo(() => {
|
||||
const {className: linkClassName} = registerLink.props as {className?: string};
|
||||
return cloneElement(registerLink, {
|
||||
@@ -193,6 +215,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
);
|
||||
}
|
||||
|
||||
if (isSsoEnforced) {
|
||||
return (
|
||||
<div className={styles.loginContainer}>
|
||||
<h1 className={styles.title}>{ssoDisplayName}</h1>
|
||||
<p className={styles.ssoSubtitle}>
|
||||
<Trans>Sign in with your organization's single sign-on provider.</Trans>
|
||||
</p>
|
||||
<Button fitContainer onClick={handleStartSso} submitting={isStartingSso} type="button">
|
||||
<Trans>Continue with SSO</Trans>
|
||||
</Button>
|
||||
{switchError && <div className={styles.loginNotice}>{switchError}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showAccountSelector && hasStoredAccounts && !desktopHandoff) {
|
||||
return (
|
||||
<AccountSelector
|
||||
@@ -237,6 +274,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
|
||||
{!showAccountSelector && switchError ? <div className={styles.loginNotice}>{switchError}</div> : null}
|
||||
|
||||
{ssoConfig?.enabled ? (
|
||||
<div className={styles.ssoBlock}>
|
||||
<Button fitContainer onClick={handleStartSso} submitting={isStartingSso} type="button">
|
||||
<Trans>Continue with SSO</Trans>
|
||||
</Button>
|
||||
<div className={styles.ssoSubtitle}>
|
||||
{ssoConfig.enforced ? (
|
||||
<Trans>SSO is required to access this workspace.</Trans>
|
||||
) : (
|
||||
<Trans>Prefer using SSO? Continue with {ssoDisplayName}.</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AuthLoginEmailPasswordForm
|
||||
form={form}
|
||||
isLoading={isLoading}
|
||||
@@ -268,7 +320,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
showBrowserOption={showBrowserPasskey}
|
||||
onBrowserLogin={handlePasskeyBrowserLogin}
|
||||
browserLabel={<Trans>Log in via browser or custom instance</Trans>}
|
||||
browserLabel={<Trans>Log in via browser</Trans>}
|
||||
/>
|
||||
|
||||
<div className={styles.footer}>
|
||||
@@ -282,5 +334,3 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export {AuthLoginLayout};
|
||||
|
||||
@@ -17,18 +17,24 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {DateOfBirthField} from '@app/components/auth/DateOfBirthField';
|
||||
import FormField from '@app/components/auth/FormField';
|
||||
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '@app/components/auth/SubmitTooltip';
|
||||
import {ExternalLink} from '@app/components/common/ExternalLink';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
|
||||
import {
|
||||
type AuthRegisterFormDraft,
|
||||
EMPTY_AUTH_REGISTER_FORM_DRAFT,
|
||||
useAuthRegisterDraftContext,
|
||||
} from '@app/contexts/AuthRegisterDraftContext';
|
||||
import {useAuthForm} from '@app/hooks/useAuthForm';
|
||||
import {useLocation} from '@app/lib/router/React';
|
||||
import {Routes} from '@app/Routes';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {useId, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {DateOfBirthField} from '~/components/auth/DateOfBirthField';
|
||||
import FormField from '~/components/auth/FormField';
|
||||
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '~/components/auth/SubmitTooltip';
|
||||
import {ExternalLink} from '~/components/common/ExternalLink';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import {useAuthForm} from '~/hooks/useAuthForm';
|
||||
import {Routes} from '~/Routes';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {useCallback, useId, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface AuthMinimalRegisterFormCoreProps {
|
||||
submitLabel: React.ReactNode;
|
||||
@@ -46,17 +52,81 @@ export function AuthMinimalRegisterFormCore({
|
||||
extraContent,
|
||||
}: AuthMinimalRegisterFormCoreProps) {
|
||||
const {t} = useLingui();
|
||||
const location = useLocation();
|
||||
const draftKey = `register:${location.pathname}${location.search}`;
|
||||
const {getRegisterFormDraft, setRegisterFormDraft, clearRegisterFormDraft} = useAuthRegisterDraftContext();
|
||||
const globalNameId = useId();
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState('');
|
||||
const [selectedDay, setSelectedDay] = useState('');
|
||||
const [selectedYear, setSelectedYear] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const initialDraft = useMemo<AuthRegisterFormDraft>(() => {
|
||||
const persistedDraft = getRegisterFormDraft(draftKey);
|
||||
if (!persistedDraft) {
|
||||
return EMPTY_AUTH_REGISTER_FORM_DRAFT;
|
||||
}
|
||||
return {
|
||||
...persistedDraft,
|
||||
formValues: {...persistedDraft.formValues},
|
||||
};
|
||||
}, [draftKey, getRegisterFormDraft]);
|
||||
const draftRef = useRef<AuthRegisterFormDraft>({
|
||||
...initialDraft,
|
||||
formValues: {...initialDraft.formValues},
|
||||
});
|
||||
|
||||
const [selectedMonth, setSelectedMonthState] = useState(initialDraft.selectedMonth);
|
||||
const [selectedDay, setSelectedDayState] = useState(initialDraft.selectedDay);
|
||||
const [selectedYear, setSelectedYearState] = useState(initialDraft.selectedYear);
|
||||
const [consent, setConsentState] = useState(initialDraft.consent);
|
||||
|
||||
const initialValues: Record<string, string> = {
|
||||
global_name: '',
|
||||
global_name: initialDraft.formValues.global_name ?? '',
|
||||
};
|
||||
|
||||
const persistDraft = useCallback(
|
||||
(partialDraft: Partial<AuthRegisterFormDraft>) => {
|
||||
const currentDraft = draftRef.current;
|
||||
const nextDraft: AuthRegisterFormDraft = {
|
||||
...currentDraft,
|
||||
...partialDraft,
|
||||
formValues: partialDraft.formValues ? {...partialDraft.formValues} : currentDraft.formValues,
|
||||
};
|
||||
draftRef.current = nextDraft;
|
||||
setRegisterFormDraft(draftKey, nextDraft);
|
||||
},
|
||||
[draftKey, setRegisterFormDraft],
|
||||
);
|
||||
|
||||
const handleMonthChange = useCallback(
|
||||
(month: string) => {
|
||||
setSelectedMonthState(month);
|
||||
persistDraft({selectedMonth: month});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleDayChange = useCallback(
|
||||
(day: string) => {
|
||||
setSelectedDayState(day);
|
||||
persistDraft({selectedDay: day});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleYearChange = useCallback(
|
||||
(year: string) => {
|
||||
setSelectedYearState(year);
|
||||
persistDraft({selectedYear: year});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleConsentChange = useCallback(
|
||||
(nextConsent: boolean) => {
|
||||
setConsentState(nextConsent);
|
||||
persistDraft({consent: nextConsent});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleRegisterSubmit = async (values: Record<string, string>) => {
|
||||
const dateOfBirth =
|
||||
selectedYear && selectedMonth && selectedDay
|
||||
@@ -65,7 +135,6 @@ export function AuthMinimalRegisterFormCore({
|
||||
|
||||
const response = await AuthenticationActionCreators.register({
|
||||
global_name: values.global_name || undefined,
|
||||
beta_code: '',
|
||||
date_of_birth: dateOfBirth,
|
||||
consent,
|
||||
invite_code: inviteCode,
|
||||
@@ -79,6 +148,7 @@ export function AuthMinimalRegisterFormCore({
|
||||
userId: response.user_id,
|
||||
});
|
||||
}
|
||||
clearRegisterFormDraft(draftKey);
|
||||
};
|
||||
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
@@ -87,10 +157,22 @@ export function AuthMinimalRegisterFormCore({
|
||||
redirectPath,
|
||||
firstFieldName: 'global_name',
|
||||
});
|
||||
|
||||
const setDraftedFormValue = useCallback(
|
||||
(fieldName: string, value: string) => {
|
||||
form.setValue(fieldName, value);
|
||||
const nextFormValues = {
|
||||
...draftRef.current.formValues,
|
||||
[fieldName]: value,
|
||||
};
|
||||
persistDraft({formValues: nextFormValues});
|
||||
},
|
||||
[form, persistDraft],
|
||||
);
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: Array<MissingField> = [];
|
||||
if (!selectedMonth || !selectedDay || !selectedYear) {
|
||||
missing.push({key: 'date_of_birth', label: t`Date of birth`});
|
||||
missing.push({key: 'date_of_birth', label: t`Date of Birth`});
|
||||
}
|
||||
return missing;
|
||||
}, [selectedMonth, selectedDay, selectedYear]);
|
||||
@@ -103,10 +185,10 @@ export function AuthMinimalRegisterFormCore({
|
||||
id={globalNameId}
|
||||
name="global_name"
|
||||
type="text"
|
||||
label={t`Display name (optional)`}
|
||||
label={t`Display Name (Optional)`}
|
||||
placeholder={t`What should people call you?`}
|
||||
value={globalNameValue}
|
||||
onChange={(value) => form.setValue('global_name', value)}
|
||||
onChange={(value) => setDraftedFormValue('global_name', value)}
|
||||
error={form.getError('global_name') || fieldErrors?.global_name}
|
||||
/>
|
||||
|
||||
@@ -114,16 +196,16 @@ export function AuthMinimalRegisterFormCore({
|
||||
selectedMonth={selectedMonth}
|
||||
selectedDay={selectedDay}
|
||||
selectedYear={selectedYear}
|
||||
onMonthChange={setSelectedMonth}
|
||||
onDayChange={setSelectedDay}
|
||||
onYearChange={setSelectedYear}
|
||||
onMonthChange={handleMonthChange}
|
||||
onDayChange={handleDayChange}
|
||||
onYearChange={handleYearChange}
|
||||
error={fieldErrors?.date_of_birth}
|
||||
/>
|
||||
|
||||
{extraContent}
|
||||
|
||||
<div className={styles.consentRow}>
|
||||
<Checkbox checked={consent} onChange={setConsent}>
|
||||
<Checkbox checked={consent} onChange={handleConsentChange}>
|
||||
<span className={styles.consentLabel}>
|
||||
<Trans>I agree to the</Trans>{' '}
|
||||
<ExternalLink href={Routes.terms()} className={styles.policyLink}>
|
||||
|
||||
@@ -17,11 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {GuildBadge} from '@app/components/guild/GuildBadge';
|
||||
import type {ReactNode} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
interface AuthPageHeaderStatProps {
|
||||
value: string | number;
|
||||
@@ -32,12 +30,11 @@ interface AuthPageHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
verified?: boolean;
|
||||
features?: ReadonlyArray<string>;
|
||||
stats?: Array<AuthPageHeaderStatProps>;
|
||||
}
|
||||
|
||||
export function AuthPageHeader({icon, title, subtitle, verified, stats}: AuthPageHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
export function AuthPageHeader({icon, title, subtitle, features, stats}: AuthPageHeaderProps) {
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
{icon}
|
||||
@@ -45,11 +42,7 @@ export function AuthPageHeader({icon, title, subtitle, verified, stats}: AuthPag
|
||||
<p className={styles.entityText}>{title}</p>
|
||||
<div className={styles.entityTitleWrapper}>
|
||||
<h2 className={styles.entityTitle}>{subtitle}</h2>
|
||||
{verified && (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.verifiedIcon} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{features && <GuildBadge features={features} />}
|
||||
</div>
|
||||
{stats && stats.length > 0 && (
|
||||
<div className={styles.entityStats}>
|
||||
|
||||
@@ -298,6 +298,28 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.suggestionLink {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-link);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: text-decoration 150ms ease;
|
||||
}
|
||||
|
||||
.suggestionLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.usernameError {
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.consentRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -369,22 +391,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.betaCodeHint {
|
||||
margin-top: -0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.usernameValidation {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: var(--background-modifier-accent);
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.giftIconContainer {
|
||||
display: flex;
|
||||
height: 5rem;
|
||||
|
||||
@@ -17,30 +17,33 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {DateOfBirthField} from '@app/components/auth/DateOfBirthField';
|
||||
import FormField from '@app/components/auth/FormField';
|
||||
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '@app/components/auth/SubmitTooltip';
|
||||
import {ExternalLink} from '@app/components/common/ExternalLink';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
|
||||
import {
|
||||
type AuthRegisterFormDraft,
|
||||
EMPTY_AUTH_REGISTER_FORM_DRAFT,
|
||||
useAuthRegisterDraftContext,
|
||||
} from '@app/contexts/AuthRegisterDraftContext';
|
||||
import {useAuthForm} from '@app/hooks/useAuthForm';
|
||||
import {useUsernameSuggestions} from '@app/hooks/useUsernameSuggestions';
|
||||
import {useLocation} from '@app/lib/router/React';
|
||||
import {Routes} from '@app/Routes';
|
||||
import AccessibilityStore from '@app/stores/AccessibilityStore';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {AnimatePresence} from 'framer-motion';
|
||||
import {useId, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {DateOfBirthField} from '~/components/auth/DateOfBirthField';
|
||||
import FormField from '~/components/auth/FormField';
|
||||
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '~/components/auth/SubmitTooltip';
|
||||
import {UsernameSuggestions} from '~/components/auth/UsernameSuggestions';
|
||||
import {ExternalLink} from '~/components/common/ExternalLink';
|
||||
import {UsernameValidationRules} from '~/components/form/UsernameValidationRules';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import {useAuthForm} from '~/hooks/useAuthForm';
|
||||
import {useUsernameSuggestions} from '~/hooks/useUsernameSuggestions';
|
||||
import {MODE} from '~/lib/env';
|
||||
import {Routes} from '~/Routes';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useCallback, useId, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface FieldConfig {
|
||||
showEmail?: boolean;
|
||||
showPassword?: boolean;
|
||||
showPasswordConfirmation?: boolean;
|
||||
showUsernameValidation?: boolean;
|
||||
showBetaCodeHint?: boolean;
|
||||
requireBetaCode?: boolean;
|
||||
}
|
||||
|
||||
interface AuthRegisterFormCoreProps {
|
||||
@@ -64,31 +67,102 @@ export function AuthRegisterFormCore({
|
||||
const {
|
||||
showEmail = false,
|
||||
showPassword = false,
|
||||
showPasswordConfirmation = false,
|
||||
showUsernameValidation = false,
|
||||
requireBetaCode = MODE !== 'development',
|
||||
} = fields;
|
||||
const location = useLocation();
|
||||
const draftKey = `register:${location.pathname}${location.search}`;
|
||||
const {getRegisterFormDraft, setRegisterFormDraft, clearRegisterFormDraft} = useAuthRegisterDraftContext();
|
||||
|
||||
const emailId = useId();
|
||||
const globalNameId = useId();
|
||||
const usernameId = useId();
|
||||
const passwordId = useId();
|
||||
const betaCodeId = useId();
|
||||
const confirmPasswordId = useId();
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState('');
|
||||
const [selectedDay, setSelectedDay] = useState('');
|
||||
const [selectedYear, setSelectedYear] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [usernameFocused, setUsernameFocused] = useState(false);
|
||||
const initialDraft = useMemo<AuthRegisterFormDraft>(() => {
|
||||
const persistedDraft = getRegisterFormDraft(draftKey);
|
||||
if (!persistedDraft) {
|
||||
return EMPTY_AUTH_REGISTER_FORM_DRAFT;
|
||||
}
|
||||
return {
|
||||
...persistedDraft,
|
||||
formValues: {...persistedDraft.formValues},
|
||||
};
|
||||
}, [draftKey, getRegisterFormDraft]);
|
||||
const draftRef = useRef<AuthRegisterFormDraft>({
|
||||
...initialDraft,
|
||||
formValues: {...initialDraft.formValues},
|
||||
});
|
||||
|
||||
const [selectedMonth, setSelectedMonthState] = useState(initialDraft.selectedMonth);
|
||||
const [selectedDay, setSelectedDayState] = useState(initialDraft.selectedDay);
|
||||
const [selectedYear, setSelectedYearState] = useState(initialDraft.selectedYear);
|
||||
const [consent, setConsentState] = useState(initialDraft.consent);
|
||||
const [_usernameFocused, setUsernameFocused] = useState(false);
|
||||
|
||||
const initialValues: Record<string, string> = {
|
||||
global_name: '',
|
||||
username: '',
|
||||
betaCode: '',
|
||||
global_name: initialDraft.formValues.global_name ?? '',
|
||||
username: initialDraft.formValues.username ?? '',
|
||||
};
|
||||
if (showEmail) initialValues.email = '';
|
||||
if (showPassword) initialValues.password = '';
|
||||
if (showEmail) initialValues.email = initialDraft.formValues.email ?? '';
|
||||
if (showPassword) initialValues.password = initialDraft.formValues.password ?? '';
|
||||
if (showPassword && showPasswordConfirmation) {
|
||||
initialValues.confirm_password = initialDraft.formValues.confirm_password ?? '';
|
||||
}
|
||||
|
||||
const persistDraft = useCallback(
|
||||
(partialDraft: Partial<AuthRegisterFormDraft>) => {
|
||||
const currentDraft = draftRef.current;
|
||||
const nextDraft: AuthRegisterFormDraft = {
|
||||
...currentDraft,
|
||||
...partialDraft,
|
||||
formValues: partialDraft.formValues ? {...partialDraft.formValues} : currentDraft.formValues,
|
||||
};
|
||||
draftRef.current = nextDraft;
|
||||
setRegisterFormDraft(draftKey, nextDraft);
|
||||
},
|
||||
[draftKey, setRegisterFormDraft],
|
||||
);
|
||||
|
||||
const handleMonthChange = useCallback(
|
||||
(month: string) => {
|
||||
setSelectedMonthState(month);
|
||||
persistDraft({selectedMonth: month});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleDayChange = useCallback(
|
||||
(day: string) => {
|
||||
setSelectedDayState(day);
|
||||
persistDraft({selectedDay: day});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleYearChange = useCallback(
|
||||
(year: string) => {
|
||||
setSelectedYearState(year);
|
||||
persistDraft({selectedYear: year});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleConsentChange = useCallback(
|
||||
(nextConsent: boolean) => {
|
||||
setConsentState(nextConsent);
|
||||
persistDraft({consent: nextConsent});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleRegisterSubmit = async (values: Record<string, string>) => {
|
||||
if (showPasswordConfirmation && showPassword && values.password !== values.confirm_password) {
|
||||
form.setError('confirm_password', t`Passwords do not match`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dateOfBirth =
|
||||
selectedYear && selectedMonth && selectedDay
|
||||
? `${selectedYear}-${selectedMonth.padStart(2, '0')}-${selectedDay.padStart(2, '0')}`
|
||||
@@ -99,7 +173,6 @@ export function AuthRegisterFormCore({
|
||||
username: values.username || undefined,
|
||||
email: showEmail ? values.email : undefined,
|
||||
password: showPassword ? values.password : undefined,
|
||||
beta_code: values.betaCode || '',
|
||||
date_of_birth: dateOfBirth,
|
||||
consent,
|
||||
invite_code: inviteCode,
|
||||
@@ -113,6 +186,7 @@ export function AuthRegisterFormCore({
|
||||
userId: response.user_id,
|
||||
});
|
||||
}
|
||||
clearRegisterFormDraft(draftKey);
|
||||
};
|
||||
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
@@ -122,6 +196,18 @@ export function AuthRegisterFormCore({
|
||||
firstFieldName: showEmail ? 'email' : 'global_name',
|
||||
});
|
||||
|
||||
const setDraftedFormValue = useCallback(
|
||||
(fieldName: string, value: string) => {
|
||||
form.setValue(fieldName, value);
|
||||
const nextFormValues = {
|
||||
...draftRef.current.formValues,
|
||||
[fieldName]: value,
|
||||
};
|
||||
persistDraft({formValues: nextFormValues});
|
||||
},
|
||||
[form, persistDraft],
|
||||
);
|
||||
|
||||
const {suggestions} = useUsernameSuggestions({
|
||||
globalName: form.getValue('global_name'),
|
||||
username: form.getValue('username'),
|
||||
@@ -135,17 +221,36 @@ export function AuthRegisterFormCore({
|
||||
if (showPassword && !form.getValue('password')) {
|
||||
missing.push({key: 'password', label: t`Password`});
|
||||
}
|
||||
if (!selectedMonth || !selectedDay || !selectedYear) {
|
||||
missing.push({key: 'date_of_birth', label: t`Date of birth`});
|
||||
if (showPassword && showPasswordConfirmation && !form.getValue('confirm_password')) {
|
||||
missing.push({key: 'confirm_password', label: t`Confirm Password`});
|
||||
}
|
||||
if (requireBetaCode && !form.getValue('betaCode')) {
|
||||
missing.push({key: 'betaCode', label: t`Beta code`});
|
||||
if (!selectedMonth || !selectedDay || !selectedYear) {
|
||||
missing.push({key: 'date_of_birth', label: t`Date of Birth`});
|
||||
}
|
||||
return missing;
|
||||
}, [form, selectedMonth, selectedDay, selectedYear, showEmail, showPassword, requireBetaCode]);
|
||||
}, [form, selectedMonth, selectedDay, selectedYear, showEmail, showPassword, showPasswordConfirmation]);
|
||||
|
||||
type HelperTextState = {type: 'error'; message: string} | {type: 'suggestion'; username: string} | {type: 'hint'};
|
||||
|
||||
const usernameValue = form.getValue('username');
|
||||
const showValidationRules = showUsernameValidation && usernameValue && (usernameFocused || usernameValue.length > 0);
|
||||
const helperTextState = useMemo<HelperTextState>(() => {
|
||||
const trimmed = usernameValue?.trim() || '';
|
||||
|
||||
if (showUsernameValidation && trimmed.length > 0) {
|
||||
if (trimmed.length > 32) {
|
||||
return {type: 'error', message: t`Username must be 32 characters or less`};
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
|
||||
return {type: 'error', message: t`Only letters, numbers, and underscores`};
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.length === 0 && suggestions.length === 1) {
|
||||
return {type: 'suggestion', username: suggestions[0]};
|
||||
}
|
||||
|
||||
return {type: 'hint'};
|
||||
}, [usernameValue, suggestions, showUsernameValidation, t]);
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={form.handleSubmit}>
|
||||
@@ -158,7 +263,7 @@ export function AuthRegisterFormCore({
|
||||
required
|
||||
label={t`Email`}
|
||||
value={form.getValue('email')}
|
||||
onChange={(value) => form.setValue('email', value)}
|
||||
onChange={(value) => setDraftedFormValue('email', value)}
|
||||
error={form.getError('email') || fieldErrors?.email}
|
||||
/>
|
||||
)}
|
||||
@@ -167,10 +272,10 @@ export function AuthRegisterFormCore({
|
||||
id={globalNameId}
|
||||
name="global_name"
|
||||
type="text"
|
||||
label={t`Display name (optional)`}
|
||||
label={t`Display Name (Optional)`}
|
||||
placeholder={t`What should people call you?`}
|
||||
value={form.getValue('global_name')}
|
||||
onChange={(value) => form.setValue('global_name', value)}
|
||||
onChange={(value) => setDraftedFormValue('global_name', value)}
|
||||
error={form.getError('global_name') || fieldErrors?.global_name}
|
||||
/>
|
||||
|
||||
@@ -180,32 +285,60 @@ export function AuthRegisterFormCore({
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
label={t`Username (optional)`}
|
||||
label={t`Username (Optional)`}
|
||||
placeholder={t`Leave blank for a random username`}
|
||||
value={usernameValue}
|
||||
onChange={(value) => form.setValue('username', value)}
|
||||
onChange={(value) => setDraftedFormValue('username', value)}
|
||||
onFocus={() => setUsernameFocused(true)}
|
||||
onBlur={() => setUsernameFocused(false)}
|
||||
error={form.getError('username') || fieldErrors?.username}
|
||||
/>
|
||||
<span className={styles.usernameHint}>
|
||||
<Trans>A 4-digit tag will be added automatically to ensure uniqueness</Trans>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showUsernameValidation && (
|
||||
<AnimatePresence>
|
||||
{showValidationRules && (
|
||||
<div className={styles.usernameValidation}>
|
||||
<UsernameValidationRules username={usernameValue} />
|
||||
</div>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{helperTextState.type === 'error' && (
|
||||
<motion.span
|
||||
key="error"
|
||||
className={styles.usernameError}
|
||||
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
|
||||
>
|
||||
{helperTextState.message}
|
||||
</motion.span>
|
||||
)}
|
||||
{helperTextState.type === 'suggestion' && (
|
||||
<motion.span
|
||||
key="suggestion"
|
||||
className={styles.usernameHint}
|
||||
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
|
||||
>
|
||||
<Trans>How about:</Trans>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.suggestionLink}
|
||||
onClick={() => setDraftedFormValue('username', helperTextState.username)}
|
||||
>
|
||||
{helperTextState.username}
|
||||
</button>
|
||||
</motion.span>
|
||||
)}
|
||||
{helperTextState.type === 'hint' && (
|
||||
<motion.span
|
||||
key="hint"
|
||||
className={styles.usernameHint}
|
||||
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
|
||||
>
|
||||
<Trans>A 4-digit tag will be added automatically to ensure uniqueness</Trans>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{!usernameValue && (
|
||||
<UsernameSuggestions suggestions={suggestions} onSelect={(username) => form.setValue('username', username)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPassword && (
|
||||
<FormField
|
||||
@@ -216,31 +349,21 @@ export function AuthRegisterFormCore({
|
||||
required
|
||||
label={t`Password`}
|
||||
value={form.getValue('password')}
|
||||
onChange={(value) => form.setValue('password', value)}
|
||||
onChange={(value) => setDraftedFormValue('password', value)}
|
||||
error={form.getError('password') || fieldErrors?.password}
|
||||
/>
|
||||
)}
|
||||
|
||||
{requireBetaCode ? (
|
||||
{showPassword && showPasswordConfirmation && (
|
||||
<FormField
|
||||
id={betaCodeId}
|
||||
name="betaCode"
|
||||
type="text"
|
||||
id={confirmPasswordId}
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
label={t`Beta code`}
|
||||
value={form.getValue('betaCode')}
|
||||
onChange={(value) => form.setValue('betaCode', value)}
|
||||
error={form.getError('betaCode') || fieldErrors?.beta_code}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
id={betaCodeId}
|
||||
name="betaCode"
|
||||
type="text"
|
||||
label={t`Beta code (optional)`}
|
||||
value={form.getValue('betaCode')}
|
||||
onChange={(value) => form.setValue('betaCode', value)}
|
||||
error={form.getError('betaCode') || fieldErrors?.beta_code}
|
||||
label={t`Confirm Password`}
|
||||
value={form.getValue('confirm_password')}
|
||||
onChange={(value) => setDraftedFormValue('confirm_password', value)}
|
||||
error={form.getError('confirm_password')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -248,16 +371,16 @@ export function AuthRegisterFormCore({
|
||||
selectedMonth={selectedMonth}
|
||||
selectedDay={selectedDay}
|
||||
selectedYear={selectedYear}
|
||||
onMonthChange={setSelectedMonth}
|
||||
onDayChange={setSelectedDay}
|
||||
onYearChange={setSelectedYear}
|
||||
onMonthChange={handleMonthChange}
|
||||
onDayChange={handleDayChange}
|
||||
onYearChange={handleYearChange}
|
||||
error={fieldErrors?.date_of_birth}
|
||||
/>
|
||||
|
||||
{extraContent}
|
||||
|
||||
<div className={styles.consentRow}>
|
||||
<Checkbox checked={consent} onChange={setConsent}>
|
||||
<Checkbox checked={consent} onChange={handleConsentChange}>
|
||||
<span className={styles.consentLabel}>
|
||||
<Trans>I agree to the</Trans>{' '}
|
||||
<ExternalLink href={Routes.terms()} className={styles.policyLink}>
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
|
||||
import {Link as RouterLink} from '@app/lib/router/React';
|
||||
import type {ReactNode} from 'react';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {Link as RouterLink} from '~/lib/router';
|
||||
|
||||
interface AuthRouterLinkProps {
|
||||
ringOffset?: number;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
to: string;
|
||||
search?: Record<string, string | undefined>;
|
||||
search?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function AuthRouterLink({ringOffset = -2, children, className, to, search}: AuthRouterLinkProps) {
|
||||
|
||||
@@ -17,24 +17,21 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import styles from '@app/components/auth/BrowserLoginHandoffModal.module.css';
|
||||
import {Input} from '@app/components/form/Input';
|
||||
import * as Modal from '@app/components/modals/Modal';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
|
||||
import {getElectronAPI, openExternalUrl} from '@app/utils/NativeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {ArrowSquareOutIcon, CheckCircleIcon} from '@phosphor-icons/react';
|
||||
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import * as Modal from '~/components/modals/Modal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {IS_DEV} from '~/lib/env';
|
||||
import HttpClient from '~/lib/HttpClient';
|
||||
import RuntimeConfigStore, {describeApiEndpoint, type InstanceDiscoveryResponse} from '~/stores/RuntimeConfigStore';
|
||||
import {isDesktop, openExternalUrl} from '~/utils/NativeUtils';
|
||||
|
||||
import styles from './BrowserLoginHandoffModal.module.css';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface LoginSuccessPayload {
|
||||
token: string;
|
||||
@@ -47,35 +44,9 @@ interface BrowserLoginHandoffModalProps {
|
||||
prefillEmail?: string;
|
||||
}
|
||||
|
||||
interface ValidatedInstance {
|
||||
apiEndpoint: string;
|
||||
webAppUrl: string;
|
||||
}
|
||||
|
||||
type ModalView = 'main' | 'instance';
|
||||
|
||||
const CODE_LENGTH = 8;
|
||||
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{8}$/;
|
||||
|
||||
const normalizeEndpoint = (input: string): string => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('API endpoint is required');
|
||||
}
|
||||
|
||||
let candidate = trimmed;
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(candidate)) {
|
||||
candidate = `https://${candidate}`;
|
||||
}
|
||||
|
||||
const url = new URL(candidate);
|
||||
if (url.pathname === '' || url.pathname === '/') {
|
||||
url.pathname = '/api';
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, '');
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const formatCodeForDisplay = (raw: string): string => {
|
||||
const cleaned = raw
|
||||
.replace(/[^A-Za-z0-9]/g, '')
|
||||
@@ -95,24 +66,46 @@ const extractRawCode = (formatted: string): string => {
|
||||
.slice(0, CODE_LENGTH);
|
||||
};
|
||||
|
||||
function normalizeInstanceOrigin(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Instance URL is required');
|
||||
}
|
||||
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new Error('Instance URL must use http or https');
|
||||
}
|
||||
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
const BrowserLoginHandoffModal = observer(
|
||||
({onSuccess, targetWebAppUrl, prefillEmail}: BrowserLoginHandoffModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
|
||||
const [view, setView] = React.useState<ModalView>('main');
|
||||
const [code, setCode] = React.useState('');
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const electronApi = getElectronAPI();
|
||||
const switchInstanceUrl = electronApi?.switchInstanceUrl;
|
||||
const canSwitchInstanceUrl = typeof switchInstanceUrl === 'function';
|
||||
|
||||
const [customInstance, setCustomInstance] = React.useState('');
|
||||
const [instanceValidating, setInstanceValidating] = React.useState(false);
|
||||
const [instanceError, setInstanceError] = React.useState<string | null>(null);
|
||||
const [validatedInstance, setValidatedInstance] = React.useState<ValidatedInstance | null>(null);
|
||||
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
|
||||
const [instanceUrl, setInstanceUrl] = useState(() => targetWebAppUrl ?? currentWebAppUrl);
|
||||
const [instanceUrlError, setInstanceUrlError] = useState<string | null>(null);
|
||||
|
||||
const showInstanceOption = IS_DEV || isDesktop();
|
||||
const [code, setCode] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const switchingInstanceRef = useRef(false);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
const instanceUrlHelper = useMemo(
|
||||
() => (canSwitchInstanceUrl ? i18n._(msg`The URL of the Fluxer instance you want to sign in to.`) : null),
|
||||
[canSwitchInstanceUrl, i18n],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (rawCode: string) => {
|
||||
if (!VALID_CODE_PATTERN.test(rawCode)) {
|
||||
return;
|
||||
@@ -120,15 +113,42 @@ const BrowserLoginHandoffModal = observer(
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setInstanceUrlError(null);
|
||||
|
||||
try {
|
||||
const customApiEndpoint = validatedInstance?.apiEndpoint;
|
||||
const result = await AuthenticationActionCreators.pollDesktopHandoffStatus(rawCode, customApiEndpoint);
|
||||
if (canSwitchInstanceUrl) {
|
||||
const trimmedInstanceUrl = instanceUrl.trim();
|
||||
if (trimmedInstanceUrl) {
|
||||
let instanceOrigin: string;
|
||||
try {
|
||||
instanceOrigin = normalizeInstanceOrigin(trimmedInstanceUrl);
|
||||
} catch {
|
||||
setInstanceUrlError(
|
||||
i18n._(msg`Invalid instance URL. Try something like "example.com" or "https://example.com".`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (instanceOrigin !== window.location.origin) {
|
||||
try {
|
||||
switchingInstanceRef.current = true;
|
||||
await switchInstanceUrl({
|
||||
instanceUrl: instanceOrigin,
|
||||
desktopHandoffCode: rawCode,
|
||||
});
|
||||
} catch (switchError) {
|
||||
switchingInstanceRef.current = false;
|
||||
const detail = switchError instanceof Error ? switchError.message : String(switchError);
|
||||
setInstanceUrlError(detail);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await AuthenticationActionCreators.pollDesktopHandoffStatus(rawCode);
|
||||
|
||||
if (result.status === 'completed' && result.token && result.user_id) {
|
||||
if (customApiEndpoint) {
|
||||
await RuntimeConfigStore.connectToEndpoint(customApiEndpoint);
|
||||
}
|
||||
await onSuccess({token: result.token, userId: result.user_id});
|
||||
ModalActionCreators.pop();
|
||||
return;
|
||||
@@ -143,17 +163,25 @@ const BrowserLoginHandoffModal = observer(
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (!switchingInstanceRef.current) {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[i18n, onSuccess, validatedInstance],
|
||||
[canSwitchInstanceUrl, i18n, instanceUrl, onSuccess, switchInstanceUrl],
|
||||
);
|
||||
|
||||
const handleCodeChange = React.useCallback(
|
||||
const handleInstanceUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInstanceUrl(e.target.value);
|
||||
setInstanceUrlError(null);
|
||||
}, []);
|
||||
|
||||
const handleCodeChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawCode = extractRawCode(e.target.value);
|
||||
setCode(rawCode);
|
||||
setError(null);
|
||||
setInstanceUrlError(null);
|
||||
|
||||
if (VALID_CODE_PATTERN.test(rawCode)) {
|
||||
void handleSubmit(rawCode);
|
||||
@@ -162,126 +190,61 @@ const BrowserLoginHandoffModal = observer(
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
const handleOpenBrowser = React.useCallback(async () => {
|
||||
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
|
||||
const baseUrl = validatedInstance?.webAppUrl || targetWebAppUrl || currentWebAppUrl;
|
||||
const handleOpenBrowser = useCallback(async () => {
|
||||
const fallbackUrl = targetWebAppUrl || currentWebAppUrl;
|
||||
let baseUrl = fallbackUrl;
|
||||
|
||||
const params = new URLSearchParams({desktop_handoff: '1'});
|
||||
if (canSwitchInstanceUrl && instanceUrl.trim()) {
|
||||
try {
|
||||
baseUrl = normalizeInstanceOrigin(instanceUrl);
|
||||
} catch {
|
||||
setInstanceUrlError(
|
||||
i18n._(msg`Invalid instance URL. Try something like "example.com" or "https://example.com".`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const loginUrl = new URL('/login', baseUrl);
|
||||
loginUrl.searchParams.set('desktop_handoff', '1');
|
||||
if (prefillEmail) {
|
||||
params.set('email', prefillEmail);
|
||||
loginUrl.searchParams.set('email', prefillEmail);
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/login?${params.toString()}`;
|
||||
await openExternalUrl(url);
|
||||
}, [prefillEmail, targetWebAppUrl, validatedInstance]);
|
||||
await openExternalUrl(loginUrl.toString());
|
||||
}, [canSwitchInstanceUrl, currentWebAppUrl, i18n, instanceUrl, prefillEmail, targetWebAppUrl]);
|
||||
|
||||
const handleShowInstanceView = React.useCallback(() => {
|
||||
setView('instance');
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleBackToMain = React.useCallback(() => {
|
||||
setView('main');
|
||||
setInstanceError(null);
|
||||
}, []);
|
||||
|
||||
const handleSaveInstance = React.useCallback(async () => {
|
||||
if (!customInstance.trim()) {
|
||||
setInstanceError(i18n._(msg`Please enter an API endpoint.`));
|
||||
return;
|
||||
}
|
||||
|
||||
setInstanceValidating(true);
|
||||
setInstanceError(null);
|
||||
|
||||
try {
|
||||
const apiEndpoint = normalizeEndpoint(customInstance);
|
||||
const instanceUrl = `${apiEndpoint}/instance`;
|
||||
|
||||
const response = await HttpClient.get<InstanceDiscoveryResponse>({url: instanceUrl});
|
||||
|
||||
if (!response.ok) {
|
||||
const status = String(response.status);
|
||||
throw new Error(i18n._(msg`Failed to reach instance (${status})`));
|
||||
}
|
||||
|
||||
const instance = response.body;
|
||||
if (!instance.endpoints?.webapp) {
|
||||
throw new Error(i18n._(msg`Invalid instance response: missing webapp URL.`));
|
||||
}
|
||||
|
||||
const webAppUrl = instance.endpoints.webapp.replace(/\/$/, '');
|
||||
setValidatedInstance({apiEndpoint, webAppUrl});
|
||||
setView('main');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setInstanceError(message);
|
||||
} finally {
|
||||
setInstanceValidating(false);
|
||||
}
|
||||
}, [customInstance, i18n]);
|
||||
|
||||
const handleClearInstance = React.useCallback(() => {
|
||||
setValidatedInstance(null);
|
||||
setCustomInstance('');
|
||||
setInstanceError(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (view === 'main') {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
if (view === 'instance') {
|
||||
return (
|
||||
<Modal.Root size="small" centered onClose={ModalActionCreators.pop}>
|
||||
<Modal.Header title={i18n._(msg`Custom instance`)} />
|
||||
<Modal.Content className={styles.content}>
|
||||
<Input
|
||||
label={i18n._(msg`API Endpoint`)}
|
||||
type="url"
|
||||
placeholder="https://api.example.com"
|
||||
value={customInstance}
|
||||
onChange={(e) => {
|
||||
setCustomInstance(e.target.value);
|
||||
setInstanceError(null);
|
||||
}}
|
||||
error={instanceError ?? undefined}
|
||||
disabled={instanceValidating}
|
||||
footer={
|
||||
!instanceError ? (
|
||||
<p className={styles.inputHelper}>
|
||||
<Trans>Enter the API endpoint of the Fluxer instance you want to connect to.</Trans>
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleBackToMain} disabled={instanceValidating}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSaveInstance}
|
||||
disabled={instanceValidating || !customInstance.trim()}
|
||||
>
|
||||
{instanceValidating ? <Trans>Checking...</Trans> : <Trans>Save</Trans>}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal.Root size="small" centered onClose={ModalActionCreators.pop}>
|
||||
<Modal.Header title={i18n._(msg`Add account`)} />
|
||||
<Modal.Content className={styles.content}>
|
||||
<Modal.Content contentClassName={styles.content}>
|
||||
<p className={styles.description}>
|
||||
<Trans>Log in using your browser, then enter the code shown to add the account.</Trans>
|
||||
</p>
|
||||
|
||||
{canSwitchInstanceUrl ? (
|
||||
<div className={styles.codeInputSection}>
|
||||
<Input
|
||||
label={i18n._(msg`Instance URL`)}
|
||||
value={instanceUrl}
|
||||
onChange={handleInstanceUrlChange}
|
||||
error={instanceUrlError ?? undefined}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="url"
|
||||
placeholder="example.com"
|
||||
footer={
|
||||
instanceUrlHelper && !instanceUrlError ? (
|
||||
<p className={styles.inputHelper}>{instanceUrlHelper}</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.codeInputSection}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
@@ -294,22 +257,6 @@ const BrowserLoginHandoffModal = observer(
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validatedInstance ? (
|
||||
<div className={styles.instanceBadge}>
|
||||
<CheckCircleIcon size={14} weight="fill" className={styles.instanceBadgeIcon} />
|
||||
<span className={styles.instanceBadgeText}>
|
||||
<Trans>Using {describeApiEndpoint(validatedInstance.apiEndpoint)}</Trans>
|
||||
</span>
|
||||
<button type="button" className={styles.instanceBadgeClear} onClick={handleClearInstance}>
|
||||
<Trans>Clear</Trans>
|
||||
</button>
|
||||
</div>
|
||||
) : showInstanceOption ? (
|
||||
<button type="button" className={styles.instanceLink} onClick={handleShowInstanceView}>
|
||||
<Trans>I want to use a custom Fluxer instance</Trans>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{prefillEmail ? (
|
||||
<p className={styles.prefillHint}>
|
||||
<Trans>We will prefill {prefillEmail} once the browser login opens.</Trans>
|
||||
|
||||
@@ -112,11 +112,6 @@
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.nativeDateInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--background-modifier-accent-focus);
|
||||
}
|
||||
|
||||
.nativeDateInput[aria-invalid='true'] {
|
||||
border-color: var(--status-danger);
|
||||
}
|
||||
|
||||
@@ -17,43 +17,20 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/DateOfBirthField.module.css';
|
||||
import {Select} from '@app/components/form/Select';
|
||||
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
|
||||
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/lib/PasswordManagerAutocomplete';
|
||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||
import {isMobileExperienceEnabled} from '@app/utils/MobileExperience';
|
||||
import {getDateFieldOrder, getMonthNames} from '@fluxer/date_utils/src/DateIntrospection';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
import {Select} from '~/components/form/Select';
|
||||
import {getCurrentLocale} from '~/utils/LocaleUtils';
|
||||
import styles from './DateOfBirthField.module.css';
|
||||
|
||||
type DateFieldType = 'month' | 'day' | 'year';
|
||||
|
||||
function isMobileWebBrowser(): boolean {
|
||||
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function getDateFieldOrder(locale: string): Array<DateFieldType> {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(new Date(2000, 0, 1));
|
||||
const order: Array<DateFieldType> = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'month' && !order.includes('month')) {
|
||||
order.push('month');
|
||||
} else if (part.type === 'day' && !order.includes('day')) {
|
||||
order.push('day');
|
||||
} else if (part.type === 'year' && !order.includes('year')) {
|
||||
order.push('year');
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
interface DateOfBirthFieldProps {
|
||||
selectedMonth: string;
|
||||
selectedDay: string;
|
||||
@@ -122,16 +99,19 @@ function NativeDatePicker({
|
||||
</legend>
|
||||
</div>
|
||||
<div className={styles.inputsContainer}>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.nativeDateInput}
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
min={minDate}
|
||||
max={maxDate}
|
||||
placeholder={dateOfBirthPlaceholder}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
<FocusRing offset={-2}>
|
||||
<input
|
||||
type="date"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
className={styles.nativeDateInput}
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
min={minDate}
|
||||
max={maxDate}
|
||||
placeholder={dateOfBirthPlaceholder}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
</FocusRing>
|
||||
{error && <span className={styles.errorText}>{error}</span>}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -159,12 +139,11 @@ export const DateOfBirthField = observer(function DateOfBirthField({
|
||||
const currentDate = new Date();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
const monthNames = getMonthNames(locale);
|
||||
const allMonths = Array.from({length: 12}, (_, index) => {
|
||||
const monthDate = new Date(2000, index, 1);
|
||||
const monthName = new Intl.DateTimeFormat(locale, {month: 'long'}).format(monthDate);
|
||||
return {
|
||||
value: String(index + 1),
|
||||
label: monthName,
|
||||
label: monthNames[index],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -195,7 +174,7 @@ export const DateOfBirthField = observer(function DateOfBirthField({
|
||||
};
|
||||
}, [selectedYear, selectedMonth, locale]);
|
||||
|
||||
if (isMobileWebBrowser()) {
|
||||
if (isMobileExperienceEnabled()) {
|
||||
return (
|
||||
<NativeDatePicker
|
||||
selectedMonth={selectedMonth}
|
||||
|
||||
@@ -17,15 +17,17 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/DesktopDeepLinkPrompt.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Platform} from '@app/lib/Platform';
|
||||
import {Routes} from '@app/Routes';
|
||||
import {buildAppProtocolUrl} from '@app/utils/AppProtocol';
|
||||
import {checkDesktopAvailable, navigateInDesktop} from '@app/utils/DesktopRpcClient';
|
||||
import {isDesktop, openExternalUrl} from '@app/utils/NativeUtils';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Routes} from '~/Routes';
|
||||
import {checkDesktopAvailable, navigateInDesktop} from '~/utils/DesktopRpcClient';
|
||||
import {isDesktop} from '~/utils/NativeUtils';
|
||||
import {Button} from '../uikit/Button/Button';
|
||||
import styles from './DesktopDeepLinkPrompt.module.css';
|
||||
|
||||
interface DesktopDeepLinkPromptProps {
|
||||
code: string;
|
||||
@@ -37,9 +39,12 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMobileBrowser = Platform.isMobileBrowser;
|
||||
const useProtocolLaunch = kind === 'invite';
|
||||
const shouldProbeDesktopAvailability = !useProtocolLaunch;
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop()) return;
|
||||
if (isDesktop() || !shouldProbeDesktopAvailability) return;
|
||||
|
||||
let cancelled = false;
|
||||
checkDesktopAvailable().then(({available}) => {
|
||||
@@ -50,11 +55,11 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [shouldProbeDesktopAvailability]);
|
||||
|
||||
if (isDesktop()) return null;
|
||||
if (isDesktop() || isMobileBrowser) return null;
|
||||
|
||||
if (desktopAvailable !== true) return null;
|
||||
if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null;
|
||||
|
||||
const getPath = (): string => {
|
||||
switch (kind) {
|
||||
@@ -73,6 +78,17 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (useProtocolLaunch) {
|
||||
try {
|
||||
await openExternalUrl(buildAppProtocolUrl(path));
|
||||
} catch {
|
||||
setError('Failed to open in desktop app');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await navigateInDesktop(path);
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import {AccountSelector} from '@app/components/accounts/AccountSelector';
|
||||
import {HandoffCodeDisplay} from '@app/components/auth/HandoffCodeDisplay';
|
||||
import {type Account, SessionExpiredError} from '@app/lib/SessionManager';
|
||||
import AccountManager from '@app/stores/AccountManager';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {AccountSelector} from '~/components/accounts/AccountSelector';
|
||||
import {HandoffCodeDisplay} from '~/components/auth/HandoffCodeDisplay';
|
||||
import {SessionExpiredError} from '~/lib/SessionManager';
|
||||
import AccountManager, {type AccountSummary} from '~/stores/AccountManager';
|
||||
|
||||
type HandoffState = 'selecting' | 'generating' | 'displaying' | 'error';
|
||||
|
||||
@@ -48,7 +48,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
const accounts = excludeCurrentUser ? allAccounts.filter((account) => account.userId !== currentUserId) : allAccounts;
|
||||
const isGenerating = handoffState === 'generating';
|
||||
|
||||
const handleSelectAccount = useCallback(async (account: AccountSummary) => {
|
||||
const handleSelectAccount = useCallback(async (account: Account) => {
|
||||
setSelectedAccountId(account.userId);
|
||||
setHandoffState('generating');
|
||||
setHandoffError(null);
|
||||
@@ -105,7 +105,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
return (
|
||||
<AccountSelector
|
||||
accounts={accounts}
|
||||
title={<Trans>Choose an account</Trans>}
|
||||
title={<Trans>Choose an Account</Trans>}
|
||||
description={<Trans>Select the account you want to sign in with on the desktop app.</Trans>}
|
||||
disabled={isGenerating}
|
||||
showInstance
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Input} from '@app/components/form/Input';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {Input} from '~/components/form/Input';
|
||||
|
||||
interface FormFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> {
|
||||
name: string;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Gift} from '@app/actions/GiftActionCreators';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {getPremiumGiftDurationText} from '@app/utils/GiftUtils';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {GiftIcon} from '@phosphor-icons/react';
|
||||
import type {Gift} from '~/actions/GiftActionCreators';
|
||||
import {getPremiumGiftDurationText} from '~/utils/giftUtils';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
interface GiftHeaderProps {
|
||||
gift: Gift;
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as TextCopyActionCreators from '@app/actions/TextCopyActionCreators';
|
||||
import styles from '@app/components/auth/HandoffCodeDisplay.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import i18n from '@app/I18n';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {CheckCircleIcon, ClipboardIcon} from '@phosphor-icons/react';
|
||||
import {useCallback, useState} from 'react';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import i18n from '~/i18n';
|
||||
import styles from './HandoffCodeDisplay.module.css';
|
||||
|
||||
interface HandoffCodeDisplayProps {
|
||||
code: string | null;
|
||||
|
||||
191
fluxer_app/src/components/auth/InstanceSelector.module.css
Normal file
191
fluxer_app/src/components/auth/InstanceSelector.module.css
Normal file
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.statusSpinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.statusSuccess {
|
||||
color: var(--status-positive);
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.dropdownToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdownToggle:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.dropdownToggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.caretIcon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.caretIconOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
margin-top: 4px;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdownHeader {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.dropdownList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdownItem:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.instanceIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.instanceDomain {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceName {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdownItem:hover .removeButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.removeButton:hover {
|
||||
color: var(--status-danger);
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background-color: hsla(0, calc(100% * var(--saturation-factor, 1)), 50%, 0.1);
|
||||
border: 1px solid hsla(0, calc(100% * var(--saturation-factor, 1)), 50%, 0.2);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.connectButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
313
fluxer_app/src/components/auth/InstanceSelector.tsx
Normal file
313
fluxer_app/src/components/auth/InstanceSelector.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/InstanceSelector.module.css';
|
||||
import {Input} from '@app/components/form/Input';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Spinner} from '@app/components/uikit/Spinner';
|
||||
import AppStorage from '@app/lib/AppStorage';
|
||||
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CaretDownIcon, CheckCircleIcon, GlobeIcon, TrashIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const RECENT_INSTANCES_KEY = 'federation_recent_instances';
|
||||
const MAX_RECENT_INSTANCES = 5;
|
||||
|
||||
export type InstanceDiscoveryStatus = 'idle' | 'discovering' | 'success' | 'error';
|
||||
|
||||
export interface InstanceInfo {
|
||||
domain: string;
|
||||
name?: string;
|
||||
lastUsed: number;
|
||||
}
|
||||
|
||||
interface InstanceSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onInstanceDiscovered?: (domain: string) => void;
|
||||
onDiscoveryStatusChange?: (status: InstanceDiscoveryStatus) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function loadRecentInstances(): Array<InstanceInfo> {
|
||||
const stored = AppStorage.getJSON<Array<InstanceInfo>>(RECENT_INSTANCES_KEY);
|
||||
if (!stored || !Array.isArray(stored)) {
|
||||
return [];
|
||||
}
|
||||
return stored.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, MAX_RECENT_INSTANCES);
|
||||
}
|
||||
|
||||
function saveRecentInstance(domain: string, name?: string): void {
|
||||
const recent = loadRecentInstances();
|
||||
const normalizedDomain = domain.toLowerCase().trim();
|
||||
|
||||
const existingIndex = recent.findIndex((inst) => inst.domain.toLowerCase() === normalizedDomain);
|
||||
if (existingIndex !== -1) {
|
||||
recent.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
recent.unshift({
|
||||
domain: normalizedDomain,
|
||||
name,
|
||||
lastUsed: Date.now(),
|
||||
});
|
||||
|
||||
AppStorage.setJSON(RECENT_INSTANCES_KEY, recent.slice(0, MAX_RECENT_INSTANCES));
|
||||
}
|
||||
|
||||
function removeRecentInstance(domain: string): void {
|
||||
const recent = loadRecentInstances();
|
||||
const normalizedDomain = domain.toLowerCase().trim();
|
||||
const filtered = recent.filter((inst) => inst.domain.toLowerCase() !== normalizedDomain);
|
||||
AppStorage.setJSON(RECENT_INSTANCES_KEY, filtered);
|
||||
}
|
||||
|
||||
export const InstanceSelector = observer(function InstanceSelector({
|
||||
value,
|
||||
onChange,
|
||||
onInstanceDiscovered,
|
||||
onDiscoveryStatusChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}: InstanceSelectorProps) {
|
||||
const {t} = useLingui();
|
||||
const [discoveryStatus, setDiscoveryStatus] = useState<InstanceDiscoveryStatus>('idle');
|
||||
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
|
||||
const [recentInstances, setRecentInstances] = useState<Array<InstanceInfo>>(() => loadRecentInstances());
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const discoveryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const updateDiscoveryStatus = useCallback(
|
||||
(status: InstanceDiscoveryStatus) => {
|
||||
setDiscoveryStatus(status);
|
||||
onDiscoveryStatusChange?.(status);
|
||||
},
|
||||
[onDiscoveryStatusChange],
|
||||
);
|
||||
|
||||
const discoverInstance = useCallback(
|
||||
async (instanceUrl: string) => {
|
||||
if (!instanceUrl.trim()) {
|
||||
updateDiscoveryStatus('idle');
|
||||
setDiscoveryError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
updateDiscoveryStatus('discovering');
|
||||
setDiscoveryError(null);
|
||||
|
||||
try {
|
||||
await RuntimeConfigStore.connectToEndpoint(instanceUrl);
|
||||
updateDiscoveryStatus('success');
|
||||
saveRecentInstance(instanceUrl);
|
||||
setRecentInstances(loadRecentInstances());
|
||||
onInstanceDiscovered?.(instanceUrl);
|
||||
} catch (error) {
|
||||
updateDiscoveryStatus('error');
|
||||
const errorMessage = error instanceof Error ? error.message : t`Failed to connect to instance`;
|
||||
setDiscoveryError(errorMessage);
|
||||
}
|
||||
},
|
||||
[onInstanceDiscovered, updateDiscoveryStatus, t],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
onChange(newValue);
|
||||
updateDiscoveryStatus('idle');
|
||||
setDiscoveryError(null);
|
||||
|
||||
if (discoveryTimeoutRef.current) {
|
||||
clearTimeout(discoveryTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (newValue.trim()) {
|
||||
discoveryTimeoutRef.current = setTimeout(() => {
|
||||
discoverInstance(newValue);
|
||||
}, 800);
|
||||
}
|
||||
},
|
||||
[onChange, discoverInstance, updateDiscoveryStatus],
|
||||
);
|
||||
|
||||
const handleSelectRecent = useCallback(
|
||||
(instance: InstanceInfo) => {
|
||||
onChange(instance.domain);
|
||||
setShowDropdown(false);
|
||||
discoverInstance(instance.domain);
|
||||
},
|
||||
[onChange, discoverInstance],
|
||||
);
|
||||
|
||||
const handleRemoveRecent = useCallback((event: React.MouseEvent, domain: string) => {
|
||||
event.stopPropagation();
|
||||
removeRecentInstance(domain);
|
||||
setRecentInstances(loadRecentInstances());
|
||||
}, []);
|
||||
|
||||
const handleConnectClick = useCallback(() => {
|
||||
if (value.trim()) {
|
||||
discoverInstance(value);
|
||||
}
|
||||
}, [value, discoverInstance]);
|
||||
|
||||
const handleDropdownToggle = useCallback(() => {
|
||||
if (recentInstances.length > 0 && !disabled) {
|
||||
setShowDropdown((prev) => !prev);
|
||||
}
|
||||
}, [recentInstances.length, disabled]);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
if (recentInstances.length > 0 && !value.trim()) {
|
||||
setShowDropdown(true);
|
||||
}
|
||||
}, [recentInstances.length, value]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (discoveryTimeoutRef.current) {
|
||||
clearTimeout(discoveryTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
if (discoveryStatus === 'discovering') {
|
||||
return <Spinner size="small" className={styles.statusSpinner} />;
|
||||
}
|
||||
if (discoveryStatus === 'success') {
|
||||
return <CheckCircleIcon weight="fill" className={styles.statusSuccess} size={18} />;
|
||||
}
|
||||
if (discoveryStatus === 'error') {
|
||||
return <WarningCircleIcon weight="fill" className={styles.statusError} size={18} />;
|
||||
}
|
||||
return null;
|
||||
}, [discoveryStatus]);
|
||||
|
||||
const placeholder = t`Enter instance URL (e.g. fluxer.app)`;
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.container, className)}>
|
||||
<div className={styles.inputContainer}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
leftIcon={<GlobeIcon size={18} weight="regular" />}
|
||||
rightElement={
|
||||
<div className={styles.inputActions}>
|
||||
{statusIcon}
|
||||
{recentInstances.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dropdownToggle}
|
||||
onClick={handleDropdownToggle}
|
||||
disabled={disabled}
|
||||
aria-label={t`Show recent instances`}
|
||||
>
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={clsx(styles.caretIcon, showDropdown && styles.caretIconOpen)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
aria-label={t`Instance URL`}
|
||||
aria-describedby={discoveryError ? 'instance-error' : undefined}
|
||||
/>
|
||||
|
||||
{showDropdown && recentInstances.length > 0 && (
|
||||
<div ref={dropdownRef} className={styles.dropdown}>
|
||||
<div className={styles.dropdownHeader}>
|
||||
<Trans>Recent instances</Trans>
|
||||
</div>
|
||||
<ul className={styles.dropdownList}>
|
||||
{recentInstances.map((instance) => (
|
||||
<li key={instance.domain}>
|
||||
<button type="button" className={styles.dropdownItem} onClick={() => handleSelectRecent(instance)}>
|
||||
<GlobeIcon size={16} weight="regular" className={styles.instanceIcon} />
|
||||
<span className={styles.instanceDomain}>{instance.domain}</span>
|
||||
{instance.name && <span className={styles.instanceName}>{instance.name}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeButton}
|
||||
onClick={(e) => handleRemoveRecent(e, instance.domain)}
|
||||
aria-label={t`Remove ${instance.domain} from recent instances`}
|
||||
>
|
||||
<TrashIcon size={14} weight="regular" />
|
||||
</button>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{discoveryError && (
|
||||
<div id="instance-error" className={styles.errorMessage}>
|
||||
{discoveryError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discoveryStatus !== 'success' && value.trim() && (
|
||||
<Button
|
||||
onClick={handleConnectClick}
|
||||
disabled={disabled}
|
||||
submitting={discoveryStatus === 'discovering'}
|
||||
variant="secondary"
|
||||
small
|
||||
className={styles.connectButton}
|
||||
>
|
||||
<Trans>Connect</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -17,20 +17,20 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {GuildBadge} from '@app/components/guild/GuildBadge';
|
||||
import {GuildIcon} from '@app/components/popouts/GuildIcon';
|
||||
import {Avatar} from '@app/components/uikit/Avatar';
|
||||
import {BaseAvatar} from '@app/components/uikit/BaseAvatar';
|
||||
import {UserRecord} from '@app/records/UserRecord';
|
||||
import {isGroupDmInvite, isGuildInvite, isPackInvite} from '@app/types/InviteTypes';
|
||||
import * as AvatarUtils from '@app/utils/AvatarUtils';
|
||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||
import {formatNumber} from '@fluxer/number_utils/src/NumberFormatting';
|
||||
import type {GroupDmInvite, GuildInvite, Invite, PackInvite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {GuildFeatures} from '~/Constants';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import {Avatar} from '~/components/uikit/Avatar';
|
||||
import {BaseAvatar} from '~/components/uikit/BaseAvatar';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import {UserRecord} from '~/records/UserRecord';
|
||||
import type {GroupDmInvite, GuildInvite, Invite, PackInvite} from '~/types/InviteTypes';
|
||||
import {isGroupDmInvite, isGuildInvite, isPackInvite} from '~/types/InviteTypes';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
|
||||
interface InviteHeaderProps {
|
||||
invite: Invite;
|
||||
@@ -52,19 +52,25 @@ interface PreviewGuildInviteHeaderProps {
|
||||
guildId: string;
|
||||
guildName: string;
|
||||
guildIcon: string | null;
|
||||
isVerified: boolean;
|
||||
features: ReadonlyArray<string>;
|
||||
presenceCount: number;
|
||||
memberCount: number;
|
||||
previewIconUrl?: string | null;
|
||||
previewName?: string | null;
|
||||
}
|
||||
|
||||
function formatInviteCount(value: number): string {
|
||||
return formatNumber(value, getCurrentLocale());
|
||||
}
|
||||
|
||||
export const GuildInviteHeader = observer(function GuildInviteHeader({invite}: GuildInviteHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
const guild = invite.guild;
|
||||
const features = Array.isArray(guild.features) ? guild.features : [...guild.features];
|
||||
const isVerified = features.includes(GuildFeatures.VERIFIED);
|
||||
const presenceCount = invite.presence_count ?? 0;
|
||||
const memberCount = invite.member_count ?? 0;
|
||||
const formattedPresenceCount = formatInviteCount(presenceCount);
|
||||
const formattedMemberCount = formatInviteCount(memberCount);
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
@@ -77,23 +83,19 @@ export const GuildInviteHeader = observer(function GuildInviteHeader({invite}: G
|
||||
</p>
|
||||
<div className={styles.entityTitleWrapper}>
|
||||
<h2 className={styles.entityTitle}>{guild.name}</h2>
|
||||
{isVerified ? (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.verifiedIcon} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<GuildBadge features={features} />
|
||||
</div>
|
||||
<div className={styles.entityStats}>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.onlineDot} />
|
||||
<span className={styles.statText}>
|
||||
<Trans>{invite.presence_count} Online</Trans>
|
||||
<Trans>{formattedPresenceCount} Online</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,6 +109,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
|
||||
const inviter = invite.inviter;
|
||||
const avatarUrl = inviter ? AvatarUtils.getUserAvatarURL(inviter, false) : null;
|
||||
const memberCount = invite.member_count ?? 0;
|
||||
const formattedMemberCount = formatInviteCount(memberCount);
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
@@ -124,7 +127,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +139,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
|
||||
export const PackInviteHeader = observer(function PackInviteHeader({invite}: PackInviteHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
const pack = invite.pack;
|
||||
const creatorRecord = React.useMemo(() => new UserRecord(pack.creator), [pack.creator]);
|
||||
const creatorRecord = useMemo(() => new UserRecord(pack.creator), [pack.creator]);
|
||||
const packKindLabel = pack.type === 'emoji' ? t`Emoji pack` : t`Sticker pack`;
|
||||
const inviterTag = invite.inviter ? `${invite.inviter.username}#${invite.inviter.discriminator}` : null;
|
||||
|
||||
@@ -183,7 +186,7 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
|
||||
guildId,
|
||||
guildName,
|
||||
guildIcon,
|
||||
isVerified,
|
||||
features,
|
||||
presenceCount,
|
||||
memberCount,
|
||||
previewIconUrl,
|
||||
@@ -191,9 +194,11 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
|
||||
}: PreviewGuildInviteHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
const displayName = previewName ?? guildName;
|
||||
const [hasPreviewIconError, setPreviewIconError] = React.useState(false);
|
||||
const formattedPresenceCount = formatInviteCount(presenceCount);
|
||||
const formattedMemberCount = formatInviteCount(memberCount);
|
||||
const [hasPreviewIconError, setPreviewIconError] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setPreviewIconError(false);
|
||||
}, [previewIconUrl]);
|
||||
|
||||
@@ -222,23 +227,19 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
|
||||
</p>
|
||||
<div className={styles.entityTitleWrapper}>
|
||||
<h2 className={styles.entityTitle}>{displayName}</h2>
|
||||
{isVerified ? (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.verifiedIcon} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<GuildBadge features={features} />
|
||||
</div>
|
||||
<div className={styles.entityStats}>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.onlineDot} />
|
||||
<span className={styles.statText}>
|
||||
<Trans>{presenceCount} Online</Trans>
|
||||
<Trans>{formattedPresenceCount} Online</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,15 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import styles from '@app/components/auth/IpAuthorizationScreen.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {IpAuthorizationChallenge} from '@app/viewmodels/auth/AuthFlow';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {EnvelopeSimpleIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import type {IpAuthorizationChallenge} from '~/hooks/useLoginFlow';
|
||||
import styles from './IpAuthorizationScreen.module.css';
|
||||
|
||||
type ConnectionState = 'connecting' | 'connected' | 'error';
|
||||
type PollingState = 'polling' | 'error';
|
||||
|
||||
interface IpAuthorizationScreenProps {
|
||||
challenge: IpAuthorizationChallenge;
|
||||
@@ -33,79 +34,67 @@ interface IpAuthorizationScreenProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_ERRORS = 3;
|
||||
|
||||
const logger = new Logger('IpAuthorizationScreen');
|
||||
|
||||
const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizationScreenProps) => {
|
||||
const [resendUsed, setResendUsed] = useState(false);
|
||||
const [resendIn, setResendIn] = useState(challenge.resendAvailableIn);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting');
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [pollingState, setPollingState] = useState<PollingState>('polling');
|
||||
const onAuthorizedRef = useRef(onAuthorized);
|
||||
onAuthorizedRef.current = onAuthorized;
|
||||
|
||||
useEffect(() => {
|
||||
setResendUsed(false);
|
||||
setResendIn(challenge.resendAvailableIn);
|
||||
setConnectionState('connecting');
|
||||
setRetryCount(0);
|
||||
setPollingState('polling');
|
||||
}, [challenge]);
|
||||
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let isMounted = true;
|
||||
let consecutiveErrors = 0;
|
||||
|
||||
const connect = () => {
|
||||
const poll = async () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
es = AuthenticationActionCreators.subscribeToIpAuthorization(challenge.ticket);
|
||||
try {
|
||||
const result = await AuthenticationActionCreators.pollIpAuthorization(challenge.ticket);
|
||||
|
||||
es.onopen = () => {
|
||||
if (isMounted) {
|
||||
setConnectionState('connected');
|
||||
setRetryCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
es.onmessage = async (event) => {
|
||||
if (!event.data) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.token && data?.user_id) {
|
||||
es?.close();
|
||||
await onAuthorizedRef.current({token: data.token, userId: data.user_id});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
if (!isMounted) return;
|
||||
|
||||
setRetryCount((prev) => {
|
||||
const newCount = prev + 1;
|
||||
if (newCount < MAX_RETRY_ATTEMPTS) {
|
||||
setConnectionState('connecting');
|
||||
retryTimeout = setTimeout(connect, RETRY_DELAY_MS);
|
||||
} else {
|
||||
setConnectionState('error');
|
||||
}
|
||||
return newCount;
|
||||
});
|
||||
};
|
||||
if (result.completed && result.token && result.user_id) {
|
||||
await onAuthorizedRef.current({token: result.token, userId: result.user_id});
|
||||
return;
|
||||
}
|
||||
|
||||
consecutiveErrors = 0;
|
||||
pollTimeout = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
|
||||
consecutiveErrors++;
|
||||
|
||||
if (consecutiveErrors >= MAX_POLL_ERRORS) {
|
||||
setPollingState('error');
|
||||
logger.error('Failed to poll IP authorization after max retries', error);
|
||||
} else {
|
||||
pollTimeout = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
es?.close();
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
if (pollTimeout) {
|
||||
clearTimeout(pollTimeout);
|
||||
}
|
||||
};
|
||||
}, [challenge.ticket]);
|
||||
}, [challenge.ticket, pollingState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
@@ -122,42 +111,36 @@ const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizatio
|
||||
setResendUsed(true);
|
||||
setResendIn(30);
|
||||
} catch (error) {
|
||||
console.error('Failed to resend IP authorization email', error);
|
||||
logger.error('Failed to resend IP authorization email', error);
|
||||
}
|
||||
}, [challenge.ticket, resendIn, resendUsed]);
|
||||
|
||||
const handleRetryConnection = useCallback(() => {
|
||||
setRetryCount(0);
|
||||
setConnectionState('connecting');
|
||||
const handleRetry = useCallback(() => {
|
||||
setPollingState('polling');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.icon}>
|
||||
{connectionState === 'error' ? (
|
||||
{pollingState === 'error' ? (
|
||||
<WarningCircleIcon size={48} weight="fill" />
|
||||
) : (
|
||||
<EnvelopeSimpleIcon size={48} weight="fill" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className={styles.title}>
|
||||
{connectionState === 'error' ? <Trans>Connection lost</Trans> : <Trans>Check your email</Trans>}
|
||||
{pollingState === 'error' ? <Trans>Connection lost</Trans> : <Trans>Check your email</Trans>}
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
{connectionState === 'error' ? (
|
||||
{pollingState === 'error' ? (
|
||||
<Trans>We lost the connection while waiting for authorization. Please try again.</Trans>
|
||||
) : (
|
||||
<Trans>We emailed a link to authorize this login. Please open your inbox for {challenge.email}.</Trans>
|
||||
)}
|
||||
</p>
|
||||
{connectionState === 'connecting' && retryCount > 0 && (
|
||||
<p className={styles.retryingText}>
|
||||
<Trans>Reconnecting...</Trans>
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
{connectionState === 'error' ? (
|
||||
<Button variant="primary" onClick={handleRetryConnection}>
|
||||
{pollingState === 'error' ? (
|
||||
<Button variant="primary" onClick={handleRetry}>
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footerButton:hover,
|
||||
.footerButton:focus {
|
||||
.footerButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import FormField from '@app/components/auth/FormField';
|
||||
import styles from '@app/components/auth/MfaScreen.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {useMfaController} from '@app/hooks/useLoginFlow';
|
||||
import type {LoginSuccessPayload, MfaChallenge} from '@app/viewmodels/auth/AuthFlow';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {useId} from 'react';
|
||||
import FormField from '~/components/auth/FormField';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {type LoginSuccessPayload, type MfaChallenge, useMfaController} from '~/hooks/useLoginFlow';
|
||||
import styles from './MfaScreen.module.css';
|
||||
|
||||
interface MfaScreenProps {
|
||||
challenge: MfaChallenge;
|
||||
|
||||
@@ -17,42 +17,21 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import authStyles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import dobStyles from '@app/components/auth/DateOfBirthField.module.css';
|
||||
import {ExternalLink} from '@app/components/common/ExternalLink';
|
||||
import inputStyles from '@app/components/form/Input.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
|
||||
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/lib/PasswordManagerAutocomplete';
|
||||
import {Routes} from '@app/Routes';
|
||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||
import {getDateFieldOrder} from '@fluxer/date_utils/src/DateIntrospection';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {useMemo} from 'react';
|
||||
import {ExternalLink} from '~/components/common/ExternalLink';
|
||||
import inputStyles from '~/components/form/Input.module.css';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import {Routes} from '~/Routes';
|
||||
import {getCurrentLocale} from '~/utils/LocaleUtils';
|
||||
import authStyles from './AuthPageStyles.module.css';
|
||||
import dobStyles from './DateOfBirthField.module.css';
|
||||
|
||||
type DateFieldType = 'month' | 'day' | 'year';
|
||||
|
||||
function getDateFieldOrder(locale: string): Array<DateFieldType> {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(new Date(2000, 0, 1));
|
||||
const order: Array<DateFieldType> = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'month' && !order.includes('month')) {
|
||||
order.push('month');
|
||||
} else if (part.type === 'day' && !order.includes('day')) {
|
||||
order.push('day');
|
||||
} else if (part.type === 'year' && !order.includes('year')) {
|
||||
order.push('year');
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
interface MockMinimalRegisterFormProps {
|
||||
submitLabel: React.ReactNode;
|
||||
}
|
||||
@@ -65,17 +44,38 @@ export function MockMinimalRegisterForm({submitLabel}: MockMinimalRegisterFormPr
|
||||
const dateFields: Record<DateFieldType, React.ReactElement> = {
|
||||
month: (
|
||||
<div key="month" className={dobStyles.monthField}>
|
||||
<input type="text" readOnly tabIndex={-1} placeholder={t`Month`} className={inputStyles.input} />
|
||||
<input
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`Month`}
|
||||
className={inputStyles.input}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
day: (
|
||||
<div key="day" className={dobStyles.dayField}>
|
||||
<input type="text" readOnly tabIndex={-1} placeholder={t`Day`} className={inputStyles.input} />
|
||||
<input
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`Day`}
|
||||
className={inputStyles.input}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
year: (
|
||||
<div key="year" className={dobStyles.yearField}>
|
||||
<input type="text" readOnly tabIndex={-1} placeholder={t`Year`} className={inputStyles.input} />
|
||||
<input
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`Year`}
|
||||
className={inputStyles.input}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -93,6 +93,7 @@ export function MockMinimalRegisterForm({submitLabel}: MockMinimalRegisterFormPr
|
||||
<div className={inputStyles.inputGroup}>
|
||||
<input
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`What should people call you?`}
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/SubmitTooltip.module.css';
|
||||
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {ReactNode} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './SubmitTooltip.module.css';
|
||||
|
||||
export interface MissingField {
|
||||
key: string;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
overflow: hidden;
|
||||
animation: slideDown 300ms ease-out;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestionsList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestionButton {
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
padding: 0.375rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
animation: fadeInScale 200ms ease-out backwards;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggestionButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.suggestionButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 500px;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import styles from './UsernameSuggestions.module.css';
|
||||
|
||||
interface UsernameSuggestionsProps {
|
||||
suggestions: Array<string>;
|
||||
onSelect: (username: string) => void;
|
||||
}
|
||||
|
||||
export const UsernameSuggestions = observer(function UsernameSuggestions({
|
||||
suggestions,
|
||||
onSelect,
|
||||
}: UsernameSuggestionsProps) {
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<p className={styles.label}>
|
||||
<Trans>Suggested usernames:</Trans>
|
||||
</p>
|
||||
<div className={styles.suggestionsList}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => onSelect(suggestion)}
|
||||
className={styles.suggestionButton}
|
||||
style={{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -17,27 +17,27 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import FormField from '@app/components/auth/FormField';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useId} from 'react';
|
||||
import FormField from '~/components/auth/FormField';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
|
||||
type FieldErrors = Record<string, string | undefined> | null | undefined;
|
||||
|
||||
export type AuthFormControllerLike = {
|
||||
export interface AuthFormControllerLike {
|
||||
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
getValue: (name: string) => string;
|
||||
setValue: (name: string, value: string) => void;
|
||||
getError: (name: string) => string | null | undefined;
|
||||
isSubmitting?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type AuthEmailPasswordFormClasses = {
|
||||
export interface AuthEmailPasswordFormClasses {
|
||||
form: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
form: AuthFormControllerLike;
|
||||
isLoading: boolean;
|
||||
fieldErrors?: FieldErrors;
|
||||
@@ -47,7 +47,7 @@ type Props = {
|
||||
links?: React.ReactNode;
|
||||
linksWrapperClassName?: string;
|
||||
disableSubmit?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AuthLoginEmailPasswordForm({
|
||||
form,
|
||||
@@ -17,15 +17,15 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
|
||||
export type AuthLoginDividerClasses = {
|
||||
interface AuthLoginDividerClasses {
|
||||
divider: string;
|
||||
dividerLine: string;
|
||||
dividerText: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthLoginDivider({
|
||||
classes,
|
||||
@@ -43,11 +43,11 @@ export function AuthLoginDivider({
|
||||
);
|
||||
}
|
||||
|
||||
export type AuthPasskeyClasses = {
|
||||
export interface AuthPasskeyClasses {
|
||||
wrapper?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
classes?: AuthPasskeyClasses;
|
||||
|
||||
disabled: boolean;
|
||||
@@ -58,7 +58,7 @@ type Props = {
|
||||
|
||||
primaryLabel?: React.ReactNode;
|
||||
browserLabel?: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AuthLoginPasskeyActions({
|
||||
classes,
|
||||
@@ -17,17 +17,17 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import {useCallback, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
|
||||
export type DesktopHandoffMode = 'idle' | 'selecting' | 'login' | 'generating' | 'displaying' | 'error';
|
||||
type DesktopHandoffMode = 'idle' | 'selecting' | 'login' | 'generating' | 'ready' | 'displaying' | 'error';
|
||||
|
||||
type Options = {
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
hasStoredAccounts: boolean;
|
||||
|
||||
initialMode?: DesktopHandoffMode;
|
||||
};
|
||||
}
|
||||
|
||||
export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}: Options) {
|
||||
const derivedInitial = useMemo<DesktopHandoffMode>(() => {
|
||||
Reference in New Issue
Block a user