Files
fluxer/fluxer_app/src/features/app/components/setup/SetupWizardSteps.tsx
T

1943 lines
69 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
import {SetupWelcomeGate} from '@app/features/app/components/setup/SetupWelcomeGate';
import type {SetupBrandingAssetKind} from '@app/features/app/components/setup/SetupWizardClient';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
import {Button} from '@app/features/ui/button/Button';
import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerField';
import {Input} from '@app/features/ui/components/form/FormInput';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {Spinner} from '@app/features/ui/components/Spinner';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import {
type AccountIdentityMode,
AccountIdentityModes,
type TagStyle,
TagStyles,
} from '@fluxer/constants/src/AccountIdentityConstants';
import {resolveSmtpTlsMode, type SmtpTlsMode, SmtpTlsModes} from '@fluxer/constants/src/SmtpConstants';
import type {ThemeType} from '@fluxer/constants/src/UserConstants';
import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ImageIcon, TrashIcon, UploadSimpleIcon} from '@phosphor-icons/react';
import {AnimatePresence, motion, type Transition, useReducedMotion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
const PUSH_RELAY_NOTICE_URL = 'https://fluxer.com/push-relay';
const DEFAULT_THEME_COLOR = 0x4641d9;
export type RegistrationMode = 'open' | 'approval' | 'closed';
export type PremiumMode = 'mirror' | 'everyone';
export interface BrandingAssetState {
kind: SetupBrandingAssetKind;
url: string | null;
preview: string | null;
}
const WELCOME_UNAUTHED_TITLE_DESCRIPTOR = msg({
message: 'Create the administrator account',
comment: 'Setup wizard title prompting the operator to create the first account.',
});
const WELCOME_UNAUTHED_BODY_DESCRIPTOR = msg({
message: 'This instance has no accounts yet. The first account you create automatically becomes the administrator.',
comment: 'Setup wizard body prompting the operator to register the first account.',
});
const WELCOME_LANGUAGE_LABEL_DESCRIPTOR = msg({
message: 'Language',
comment: 'Label for the setup wizard language selector.',
});
const THEME_TITLE_DESCRIPTOR = msg({
message: 'Choose a theme',
comment: 'Setup wizard title for choosing the initial account theme.',
});
const THEME_BODY_DESCRIPTOR = msg({
message: 'Pick the appearance this administrator account should start with.',
comment: 'Setup wizard body for choosing the initial account theme.',
});
const SIGN_IN_METHOD_TITLE_DESCRIPTOR = msg({
message: 'Choose how people sign in',
comment: 'Setup wizard title for choosing whether accounts use usernames or email addresses to sign in.',
});
const SIGN_IN_METHOD_BODY_DESCRIPTOR = msg({
message: 'You cannot change this once the first account exists.',
comment: 'Setup wizard body for the sign-in method step.',
});
const SIGN_IN_METHOD_USERNAME_NAME_DESCRIPTOR = msg({
message: 'Usernames (recommended)',
comment: 'Setup wizard option where people sign in with a username and password and no email is collected.',
});
const SIGN_IN_METHOD_USERNAME_DESC_DESCRIPTOR = msg({
message:
'People sign in with a username and password. No email address is collected. Each person can save a recovery kit in case they forget their password.',
comment: 'Description for the username sign-in option in the setup wizard.',
});
const SIGN_IN_METHOD_EMAIL_NAME_DESCRIPTOR = msg({
message: 'Email',
comment: 'Setup wizard option where people sign in with an email address and password.',
});
const SIGN_IN_METHOD_EMAIL_DESC_DESCRIPTOR = msg({
message:
'People sign in with an email address and password. Password resets and verification need email delivery, which you can set up later.',
comment: 'Description for the email sign-in option in the setup wizard.',
});
const SIGN_IN_METHOD_USERNAME_SUMMARY_DESCRIPTOR = msg({
message: 'Usernames',
comment: 'Summary value on the setup wizard finish step when people sign in with a username.',
});
const TAG_STYLE_NONE_SUMMARY_DESCRIPTOR = msg({
message: 'No tags',
comment: 'Setup wizard finish summary value when usernames are unique and shown without a tag.',
});
const TAG_STYLE_RANDOM_SUMMARY_DESCRIPTOR = msg({
message: 'Random tags',
comment: 'Setup wizard finish summary value when every username gets a random four digit tag.',
});
const TAG_STYLE_TITLE_DESCRIPTOR = msg({
message: 'Username tags',
comment: 'Heading above the choice of how usernames are tagged in the setup wizard.',
});
const TAG_STYLE_NONE_NAME_DESCRIPTOR = msg({
message: 'No tags (recommended)',
comment: 'Setup wizard option where every username is unique and shown without a tag.',
});
const TAG_STYLE_NONE_EMAIL_DESC_DESCRIPTOR = msg({
message: 'Each name belongs to one person and shows on its own, like alex.',
comment: 'Description for the no tags option when people sign in with email. Keep the example name alex.',
});
const TAG_STYLE_RANDOM_NAME_DESCRIPTOR = msg({
message: 'Random tags',
comment: 'Setup wizard option where every username gets a random four digit tag.',
});
const TAG_STYLE_RANDOM_EMAIL_DESC_DESCRIPTOR = msg({
message: 'Names get a random tag, like alex#4821, so several people can share a name.',
comment: 'Description for the random tags option when people sign in with email. Keep alex#4821.',
});
const ADMIN_RECOVERY_KIT_TITLE_DESCRIPTOR = msg({
message: 'Save your recovery kit',
comment: 'Setup wizard title asking the administrator to create an account recovery kit.',
});
const ADMIN_RECOVERY_KIT_BODY_DESCRIPTOR = msg({
message:
'Your account has no email address. If you forget your password, a recovery kit is the only way back in. Create one now and keep it somewhere safe.',
comment: 'Setup wizard body explaining why the administrator needs a recovery kit.',
});
const ADMIN_RECOVERY_KIT_CREATE_DESCRIPTOR = msg({
message: 'Create recovery kit',
comment: 'Button in the setup wizard that creates a recovery kit for the administrator account.',
});
const ADMIN_RECOVERY_KIT_RECREATE_DESCRIPTOR = msg({
message: 'Create a new kit',
comment: 'Button in the setup wizard that replaces the administrator recovery kit with a new one.',
});
const ADMIN_RECOVERY_KIT_SAVED_DESCRIPTOR = msg({
message: 'Your recovery kit is ready. You can continue.',
comment: 'Status shown in the setup wizard once the administrator has a recovery kit.',
});
const ADMIN_RECOVERY_KIT_MISSING_DESCRIPTOR = msg({
message: 'Create a recovery kit to continue.',
comment: 'Status shown in the setup wizard while the administrator has no recovery kit.',
});
const CREATE_ADMIN_ACCOUNT_DESCRIPTOR = msg({
message: 'Create administrator account',
comment: 'Button that opens the registration flow to create the first administrator account.',
});
const LOADING_TITLE_DESCRIPTOR = msg({
message: 'Preparing setup',
comment: 'Setup wizard title while the instance configuration loads.',
});
const LOADING_BODY_DESCRIPTOR = msg({
message: 'Loading the current instance configuration.',
comment: 'Setup wizard body while the instance configuration loads.',
});
const BRANDING_TITLE_DESCRIPTOR = msg({
message: 'Make the instance yours',
comment: 'Setup wizard branding step title.',
});
const BRANDING_BODY_DESCRIPTOR = msg({
message: 'Set the public product name, theme color, and image assets that clients display.',
comment: 'Setup wizard branding step body.',
});
const PRODUCT_NAME_LABEL_DESCRIPTOR = msg({
message: 'Product name',
comment: 'Label for the product name input in the setup wizard.',
});
const PRODUCT_NAME_PLACEHOLDER_DESCRIPTOR = msg({
message: 'Fluxer',
comment: 'Placeholder for the product name input in the setup wizard.',
});
const PRODUCT_NAME_ERROR_DESCRIPTOR = msg({
message: 'Enter a product name between 1 and 80 characters.',
comment: 'Validation error for the product name input in the setup wizard.',
});
const THEME_COLOR_LABEL_DESCRIPTOR = msg({
message: 'Theme color',
comment: 'Label for the theme color picker in the setup wizard.',
});
const BRANDING_ASSETS_LABEL_DESCRIPTOR = msg({
message: 'Image assets',
comment: 'Label for the branding image asset uploaders in the setup wizard.',
});
const UPLOAD_ASSET_DESCRIPTOR = msg({
message: 'Upload',
comment: 'Button that opens a file picker to upload a branding image.',
});
const REPLACE_ASSET_DESCRIPTOR = msg({
message: 'Replace',
comment: 'Button that replaces an already uploaded branding image.',
});
const CLEAR_ASSET_DESCRIPTOR = msg({
message: 'Clear',
comment: 'Button that removes an uploaded branding image.',
});
const ASSET_ICON_DESCRIPTOR = msg({
message: 'Icon',
comment: 'Label for the icon branding asset.',
});
const ASSET_SYMBOL_DESCRIPTOR = msg({
message: 'Symbol',
comment: 'Label for the symbol branding asset.',
});
const ASSET_LOGO_DESCRIPTOR = msg({
message: 'Logo',
comment: 'Label for the logo branding asset.',
});
const ASSET_WORDMARK_DESCRIPTOR = msg({
message: 'Wordmark',
comment: 'Label for the wordmark branding asset.',
});
const ASSET_FAVICON_DESCRIPTOR = msg({
message: 'Favicon',
comment: 'Label for the favicon branding asset.',
});
const ASSET_PREVIEW_ALT_DESCRIPTOR = msg({
message: 'Branding asset preview',
comment: 'Accessible label for a branding image preview in the setup wizard.',
});
const REGISTRATION_TITLE_DESCRIPTOR = msg({
message: 'Who can join',
comment: 'Setup wizard registration step title.',
});
const REGISTRATION_BODY_DESCRIPTOR = msg({
message: 'Choose how new accounts are created on this instance.',
comment: 'Setup wizard registration step body.',
});
const REGISTRATION_OPEN_NAME_DESCRIPTOR = msg({
message: 'Open',
context: 'registration-policy',
comment: 'Registration mode option allowing anyone to register.',
});
const REGISTRATION_OPEN_DESC_DESCRIPTOR = msg({
message: 'Anyone can create an account.',
comment: 'Description for the open registration mode.',
});
const REGISTRATION_APPROVAL_NAME_DESCRIPTOR = msg({
message: 'Approval required',
comment: 'Registration mode option requiring admin approval.',
});
const REGISTRATION_APPROVAL_DESC_DESCRIPTOR = msg({
message: 'Anyone can request an account, but an administrator must approve it.',
comment: 'Description for the approval registration mode.',
});
const REGISTRATION_CLOSED_NAME_DESCRIPTOR = msg({
message: 'Closed',
comment: 'Registration mode option that disables public registration.',
});
const REGISTRATION_CLOSED_DESC_DESCRIPTOR = msg({
message: 'Public registration is closed.',
comment: 'Description for the closed registration mode.',
});
const COMMUNITY_TITLE_DESCRIPTOR = msg({
message: 'Choose how your instance works',
comment: 'Setup wizard community model step title.',
});
const COMMUNITY_BODY_DESCRIPTOR = msg({
message: 'These options change how people use the instance. Some can only be chosen during setup.',
comment: 'Setup wizard community model step body.',
});
const SINGLE_COMMUNITY_LABEL_DESCRIPTOR = msg({
message: 'Single community mode',
comment: 'Label for the single community toggle in the setup wizard.',
});
const SINGLE_COMMUNITY_DESC_DESCRIPTOR = msg({
message: 'Run the instance as one community instead of many. This can only be enabled now, during setup.',
comment: 'Description for the single community toggle in the setup wizard.',
});
const SINGLE_COMMUNITY_NAME_LABEL_DESCRIPTOR = msg({
message: 'Community name',
comment: 'Label for the single community name input in the setup wizard.',
});
const SINGLE_COMMUNITY_NAME_PLACEHOLDER_DESCRIPTOR = msg({
message: 'My community',
comment: 'Placeholder for the single community name input in the setup wizard.',
});
const SINGLE_COMMUNITY_NAME_ERROR_DESCRIPTOR = msg({
message: 'Enter a community name between 1 and 100 characters.',
comment: 'Validation error for the single community name input in the setup wizard.',
});
const DISABLE_DM_LABEL_DESCRIPTOR = msg({
message: 'Disable direct messages and friend requests',
comment: 'Label for the toggle that disables direct messages and friend requests.',
});
const DISABLE_DM_DESC_DESCRIPTOR = msg({
message: 'Turn off private messaging and friends across the instance. This can be reversed only once later.',
comment: 'Description for the toggle that disables direct messages and friend requests.',
});
const MEDIA_EXPIRY_TITLE_DESCRIPTOR = msg({
message: 'Attachment expiration',
comment: 'Setup wizard media expiry step title.',
});
const MEDIA_EXPIRY_BODY_DESCRIPTOR = msg({
message: 'Choose whether uploaded attachments should expire automatically based on size.',
comment: 'Setup wizard media expiry step body.',
});
const MEDIA_EXPIRY_ENABLE_LABEL_DESCRIPTOR = msg({
message: 'Enable attachment expiration',
comment: 'Label for enabling attachment expiry during setup.',
});
const MEDIA_EXPIRY_ENABLE_DESC_DESCRIPTOR = msg({
message: 'When attachment expiration is disabled, attachments remain available until manually removed.',
comment: 'Description for the attachment expiry setup switch.',
});
const MEDIA_MIN_SIZE_LABEL_DESCRIPTOR = msg({
message: 'Small file threshold (MB)',
comment: 'Label for attachment decay minimum size threshold.',
});
const MEDIA_MAX_SIZE_LABEL_DESCRIPTOR = msg({
message: 'Large file threshold (MB)',
comment: 'Label for attachment decay maximum size threshold.',
});
const MEDIA_MAX_ELIGIBLE_SIZE_LABEL_DESCRIPTOR = msg({
message: 'Maximum eligible size (MB)',
comment: 'Label for attachment decay maximum eligible size.',
});
const MEDIA_MIN_LIFETIME_LABEL_DESCRIPTOR = msg({
message: 'Minimum lifetime (days)',
comment: 'Label for attachment decay minimum lifetime.',
});
const MEDIA_MAX_LIFETIME_LABEL_DESCRIPTOR = msg({
message: 'Maximum lifetime (days)',
comment: 'Label for attachment decay maximum lifetime.',
});
const MEDIA_CURVE_LABEL_DESCRIPTOR = msg({
message: 'Expiration curve',
comment: 'Label for attachment decay curve.',
});
const MEDIA_RENEW_THRESHOLD_LABEL_DESCRIPTOR = msg({
message: 'Renew threshold (days)',
comment: 'Label for attachment decay renewal threshold.',
});
const MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR = msg({
message: 'Renew window (days)',
comment: 'Label for attachment decay renewal window.',
});
const PUSH_RELAY_TITLE_DESCRIPTOR = msg({
message: 'Mobile push notifications',
comment: 'Setup wizard push relay consent step title.',
});
const PUSH_RELAY_BODY_DESCRIPTOR = msg({
message:
"The official Fluxer mobile apps receive notifications through Fluxer's push relay, which hands them to Apple and Google. Self-hosted UnifiedPush and ntfy endpoints never reach the relay and need no agreement.",
comment: 'Setup wizard push relay consent step body.',
});
const PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR = msg({
message: 'Accept the push relay supplemental privacy notice',
comment: 'Label for the push relay consent switch during setup.',
});
const PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR = msg({
message:
'Leaving this off keeps the relay unused and drops notifications to the official mobile apps. You can accept it later in the admin panel.',
comment: 'Description for the push relay consent switch during setup.',
});
const PUSH_RELAY_NOTICE_LINK_DESCRIPTOR = msg({
message: 'Read the supplemental privacy notice',
comment: 'Link to the push relay supplemental privacy notice shown during setup.',
});
const SERVICES_TITLE_DESCRIPTOR = msg({
message: 'Optional services',
comment: 'Setup wizard optional services step title.',
});
const SERVICES_BODY_DESCRIPTOR = msg({
message: 'Enable the integrations whose credentials you have configured. Only available services are shown.',
comment: 'Setup wizard optional services step body.',
});
const SERVICES_NONE_DESCRIPTOR = msg({
message: 'No optional services are available on this instance. You can skip this step.',
comment: 'Message shown when no optional services are available in the setup wizard.',
});
const SERVICE_GIF_LABEL_DESCRIPTOR = msg({
message: 'Klipy GIFs',
comment: 'Label for the Klipy GIF service toggle in the setup wizard.',
});
const SERVICE_GIF_DESC_DESCRIPTOR = msg({
message: 'Let people search and send GIFs powered by Klipy.',
comment: 'Description for the Klipy GIF service toggle in the setup wizard.',
});
const SERVICE_YOUTUBE_LABEL_DESCRIPTOR = msg({
message: 'YouTube enrichment',
comment: 'Label for the YouTube enrichment service toggle in the setup wizard.',
});
const SERVICE_YOUTUBE_DESC_DESCRIPTOR = msg({
message: 'Show rich previews for YouTube links.',
comment: 'Description for the YouTube enrichment service toggle in the setup wizard.',
});
const SERVICE_BLUESKY_LABEL_DESCRIPTOR = msg({
message: 'Bluesky connections',
comment: 'Label for the Bluesky connections service toggle in the setup wizard.',
});
const SERVICE_BLUESKY_DESC_DESCRIPTOR = msg({
message: 'Let people link their Bluesky account to their profile.',
comment: 'Description for the Bluesky connections service toggle in the setup wizard.',
});
const PREMIUM_TITLE_DESCRIPTOR = msg({
message: 'Choose the premium model',
comment: 'Setup wizard premium model step title.',
});
const PREMIUM_BODY_DESCRIPTOR = msg({
message: 'Decide how premium limits apply to people on this instance.',
comment: 'Setup wizard premium model step body.',
});
const PREMIUM_MIRROR_NAME_DESCRIPTOR = msg({
message: 'Mirror tiers',
comment: 'Premium model option that mirrors free and premium tiers.',
});
const PREMIUM_MIRROR_DESC_DESCRIPTOR = msg({
message:
'Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel.',
comment: 'Description for the mirror premium model.',
});
const PREMIUM_EVERYONE_NAME_DESCRIPTOR = msg({
message: 'Everyone is premium',
comment: 'Premium model option that gives everyone the highest limits.',
});
const PREMIUM_EVERYONE_DESC_DESCRIPTOR = msg({
message: 'Give everyone on this instance the highest premium limits.',
comment: 'Description for the everyone-is-premium model.',
});
const FINISH_TITLE_DESCRIPTOR = msg({
message: 'Finish setup',
comment: 'Setup wizard finish step title.',
});
const FINISH_BODY_DESCRIPTOR = msg({
message: 'Review your choices below, then finish. This saves the configuration and opens the instance.',
comment: 'Setup wizard finish step body.',
});
const SUMMARY_PRODUCT_NAME_DESCRIPTOR = msg({
message: 'Product name',
comment: 'Summary row label for the product name in the setup wizard.',
});
const SUMMARY_REGISTRATION_DESCRIPTOR = msg({
message: 'Registration',
comment: 'Summary row label for the registration mode in the setup wizard.',
});
const SUMMARY_SINGLE_COMMUNITY_DESCRIPTOR = msg({
message: 'Single community',
comment: 'Summary row label for the single community choice in the setup wizard.',
});
const SUMMARY_DIRECT_MESSAGES_DESCRIPTOR = msg({
message: 'Direct messages',
comment: 'Summary row label for the direct messages choice in the setup wizard.',
});
const SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR = msg({
message: 'Attachment expiration',
comment: 'Summary row label for the attachment expiry choice in the setup wizard.',
});
const SUMMARY_PUSH_RELAY_DESCRIPTOR = msg({
message: 'Push relay notice',
comment: 'Summary row label for the push relay consent on the setup wizard finish step.',
});
const SUMMARY_PREMIUM_DESCRIPTOR = msg({
message: 'Premium model',
comment: 'Summary row label for the premium model in the setup wizard.',
});
const SUMMARY_SIGN_IN_METHOD_DESCRIPTOR = msg({
message: 'Sign-in method',
comment: 'Summary row label for how people sign in, on the setup wizard finish step.',
});
const SUMMARY_ACCEPTED_DESCRIPTOR = msg({
message: 'Accepted',
comment: 'Summary value when the operator accepted the push relay notice.',
});
const SUMMARY_NOT_ACCEPTED_DESCRIPTOR = msg({
message: 'Not accepted',
comment: 'Summary value when the operator left the push relay notice unaccepted.',
});
const SUMMARY_ON_DESCRIPTOR = msg({
message: 'Enabled',
comment: 'Summary value when a setup option is enabled.',
});
const SUMMARY_OFF_DESCRIPTOR = msg({
message: 'Disabled',
comment: 'Summary value when a setup option is disabled.',
});
const ASSET_DESCRIPTORS: Record<SetupBrandingAssetKind, MessageDescriptor> = {
icon: ASSET_ICON_DESCRIPTOR,
symbol: ASSET_SYMBOL_DESCRIPTOR,
logo: ASSET_LOGO_DESCRIPTOR,
wordmark: ASSET_WORDMARK_DESCRIPTOR,
favicon: ASSET_FAVICON_DESCRIPTOR,
};
const SETUP_LANGUAGE_MENU_MAX_HEIGHT = 220;
interface StepHeaderProps {
title: string;
body: string;
}
const StepHeader: React.FC<StepHeaderProps> = ({title, body}) => (
<div className={styles.stepHeader} data-flx="app.self-hosted-setup-wizard-gate.step-header">
<h2 className={styles.title} data-flx="app.self-hosted-setup-wizard-gate.title">
{title}
</h2>
<p className={styles.body} data-flx="app.self-hosted-setup-wizard-gate.body">
{body}
</p>
</div>
);
export const WelcomeStep = observer(
({
productName,
isAuthenticated,
initialFocusRef,
}: {
productName: string;
isAuthenticated: boolean;
initialFocusRef?: React.Ref<HTMLElement>;
}) => {
const {i18n} = useLingui();
const currentLocale = LocaleUtils.getCurrentOrDetectedLocale();
return (
<section
ref={initialFocusRef}
className={styles.step}
tabIndex={-1}
data-flx="app.self-hosted-setup-wizard-gate.welcome-step"
>
<SetupWelcomeGate
localeCode={currentLocale}
productName={productName}
isAuthenticated={isAuthenticated}
data-flx="app.setup.setup-wizard-steps.welcome-step.setup-welcome-gate"
/>
<div className={styles.localeBlock} data-flx="app.self-hosted-setup-wizard-gate.locale-block">
<div className={styles.fieldLabel} data-flx="app.self-hosted-setup-wizard-gate.locale-label">
{i18n._(WELCOME_LANGUAGE_LABEL_DESCRIPTOR)}
</div>
<LanguageSelector
value={currentLocale}
onChange={LocaleUtils.setLocalLocale}
openMenuOnFocus={false}
maxMenuHeight={SETUP_LANGUAGE_MENU_MAX_HEIGHT}
menuPlacement="bottom"
className={styles.localeSelector}
data-flx="app.setup.setup-wizard-steps.welcome-step.locale-selector.set-local-locale"
/>
</div>
</section>
);
},
);
export const ThemeStep = observer(
({theme, onThemeChange}: {theme: ThemeType; onThemeChange: (theme: ThemeType) => void}) => {
const {i18n} = useLingui();
const themeLabel = i18n._(THEME_TITLE_DESCRIPTOR);
return (
<section className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.theme-step">
<StepHeader
title={themeLabel}
body={i18n._(THEME_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.theme-step.step-header"
/>
<div className={styles.themeBlock} data-flx="app.self-hosted-setup-wizard-gate.theme-block">
<ThemeSelector
value={theme}
onChange={onThemeChange}
ariaLabel={themeLabel}
className={styles.themeButtonGroup}
data-flx="app.setup.setup-wizard-steps.theme-step.theme-button-group.theme-change"
/>
</div>
</section>
);
},
);
export const SignInMethodStep = observer(
({
mode,
tagStyle,
disabled,
error,
onChange,
onTagStyleChange,
}: {
mode: AccountIdentityMode;
tagStyle: TagStyle;
disabled: boolean;
error: string | null;
onChange: (mode: AccountIdentityMode) => void;
onTagStyleChange: (tagStyle: TagStyle) => void;
}) => {
const {i18n} = useLingui();
const options: ReadonlyArray<RadioOption<AccountIdentityMode>> = [
{
value: AccountIdentityModes.USERNAME,
name: i18n._(SIGN_IN_METHOD_USERNAME_NAME_DESCRIPTOR),
desc: i18n._(SIGN_IN_METHOD_USERNAME_DESC_DESCRIPTOR),
},
{
value: AccountIdentityModes.EMAIL,
name: i18n._(SIGN_IN_METHOD_EMAIL_NAME_DESCRIPTOR),
desc: i18n._(SIGN_IN_METHOD_EMAIL_DESC_DESCRIPTOR),
},
];
const usernameSignIn = mode === AccountIdentityModes.USERNAME;
const tagStyleOptions: ReadonlyArray<RadioOption<TagStyle>> = [
{
value: TagStyles.NONE,
name: i18n._(TAG_STYLE_NONE_NAME_DESCRIPTOR),
desc: i18n._(TAG_STYLE_NONE_EMAIL_DESC_DESCRIPTOR),
},
{
value: TagStyles.RANDOM,
name: i18n._(TAG_STYLE_RANDOM_NAME_DESCRIPTOR),
desc: i18n._(TAG_STYLE_RANDOM_EMAIL_DESC_DESCRIPTOR),
},
];
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.sign-in-method-step">
<StepHeader
title={i18n._(SIGN_IN_METHOD_TITLE_DESCRIPTOR)}
body={i18n._(SIGN_IN_METHOD_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.sign-in-method-step.step-header"
/>
<RadioGroup
options={options}
value={mode}
onChange={onChange}
disabled={disabled}
aria-label={i18n._(SIGN_IN_METHOD_TITLE_DESCRIPTOR)}
data-flx="app.self-hosted-setup-wizard-gate.sign-in-method-radio-group"
/>
{!usernameSignIn && (
<div className={styles.usernameStyle} data-flx="app.self-hosted-setup-wizard-gate.username-style">
<h3
className={styles.usernameStyleTitle}
data-flx="app.setup.setup-wizard-steps.sign-in-method-step.username-style-title"
>
{i18n._(TAG_STYLE_TITLE_DESCRIPTOR)}
</h3>
<RadioGroup
options={tagStyleOptions}
value={tagStyle}
onChange={onTagStyleChange}
disabled={disabled}
aria-label={i18n._(TAG_STYLE_TITLE_DESCRIPTOR)}
data-flx="app.self-hosted-setup-wizard-gate.username-style-radio-group"
/>
</div>
)}
{error && (
<p
className={styles.submitError}
role="alert"
data-flx="app.self-hosted-setup-wizard-gate.sign-in-method-error"
>
{error}
</p>
)}
</section>
);
},
);
export const AdminIntroStep = observer(() => {
const {i18n} = useLingui();
return (
<section className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.admin-intro-step">
<StepHeader
title={i18n._(WELCOME_UNAUTHED_TITLE_DESCRIPTOR)}
body={i18n._(WELCOME_UNAUTHED_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.admin-intro-step.step-header"
/>
</section>
);
});
export const AdminAccountStep = observer(({theme, usernameSignIn}: {theme: ThemeType; usernameSignIn: boolean}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
redirectPath=""
runtimeSnapshot={RuntimeConfig.getSnapshot()}
offerRecoveryKit={usernameSignIn}
theme={theme}
showLegalConsent={false}
data-flx="app.self-hosted-setup-wizard-gate.admin-register-form"
/>
</div>
</section>
);
});
export const AdminRecoveryKitStep = observer(
({hasRecoveryKit, creating, onCreate}: {hasRecoveryKit: boolean; creating: boolean; onCreate: () => void}) => {
const {i18n} = useLingui();
return (
<section className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.admin-recovery-kit-step">
<StepHeader
title={i18n._(ADMIN_RECOVERY_KIT_TITLE_DESCRIPTOR)}
body={i18n._(ADMIN_RECOVERY_KIT_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.admin-recovery-kit-step.step-header"
/>
<Button
variant={hasRecoveryKit ? 'secondary' : 'primary'}
submitting={creating}
onClick={onCreate}
data-flx="app.self-hosted-setup-wizard-gate.admin-recovery-kit-step.button.create"
>
{hasRecoveryKit
? i18n._(ADMIN_RECOVERY_KIT_RECREATE_DESCRIPTOR)
: i18n._(ADMIN_RECOVERY_KIT_CREATE_DESCRIPTOR)}
</Button>
<p className={styles.body} role="status" data-flx="app.self-hosted-setup-wizard-gate.admin-recovery-kit-status">
{hasRecoveryKit ? i18n._(ADMIN_RECOVERY_KIT_SAVED_DESCRIPTOR) : i18n._(ADMIN_RECOVERY_KIT_MISSING_DESCRIPTOR)}
</p>
</section>
);
},
);
export const LoadingStep = observer(() => {
const {i18n} = useLingui();
return (
<section className={styles.centeredStep} data-flx="app.self-hosted-setup-wizard-gate.loading-step">
<Spinner size="large" data-flx="app.self-hosted-setup-wizard-gate.loading-spinner" />
<StepHeader
title={i18n._(LOADING_TITLE_DESCRIPTOR)}
body={i18n._(LOADING_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.loading-step.step-header"
/>
</section>
);
});
interface BrandingAssetRowProps {
asset: BrandingAssetState;
disabled: boolean;
onUpload: (kind: SetupBrandingAssetKind) => void;
onClear: (kind: SetupBrandingAssetKind) => void;
}
const BrandingAssetRow = observer(({asset, disabled, onUpload, onClear}: BrandingAssetRowProps) => {
const {i18n} = useLingui();
const previewSrc = asset.preview ?? asset.url;
const hasImage = Boolean(previewSrc);
return (
<div className={styles.assetRow} data-flx="app.self-hosted-setup-wizard-gate.asset-row">
<div className={styles.assetPreview} data-flx="app.self-hosted-setup-wizard-gate.asset-preview">
{previewSrc ? (
<img
src={previewSrc}
alt={i18n._(ASSET_PREVIEW_ALT_DESCRIPTOR)}
className={styles.assetImage}
data-flx="app.self-hosted-setup-wizard-gate.asset-image"
/>
) : (
<ImageIcon size={22} weight="bold" data-flx="app.self-hosted-setup-wizard-gate.asset-placeholder" />
)}
</div>
<div className={styles.assetLabel} data-flx="app.self-hosted-setup-wizard-gate.asset-label">
{i18n._(ASSET_DESCRIPTORS[asset.kind])}
</div>
<div className={styles.assetActions} data-flx="app.self-hosted-setup-wizard-gate.asset-actions">
<Button
variant="secondary"
small
disabled={disabled}
leftIcon={
<UploadSimpleIcon
size={16}
weight="bold"
data-flx="app.setup.setup-wizard-steps.branding-asset-row.upload-simple-icon"
/>
}
onClick={() => onUpload(asset.kind)}
data-flx="app.self-hosted-setup-wizard-gate.asset-upload-button"
>
{hasImage ? i18n._(REPLACE_ASSET_DESCRIPTOR) : i18n._(UPLOAD_ASSET_DESCRIPTOR)}
</Button>
{hasImage && (
<Button
variant="secondary"
small
square
icon={
<TrashIcon
size={16}
weight="bold"
data-flx="app.setup.setup-wizard-steps.branding-asset-row.trash-icon"
/>
}
aria-label={i18n._(CLEAR_ASSET_DESCRIPTOR)}
disabled={disabled}
onClick={() => onClear(asset.kind)}
data-flx="app.self-hosted-setup-wizard-gate.asset-clear-button"
/>
)}
</div>
</div>
);
});
export const BrandingStep = observer(
({
productName,
productNameError,
themeColor,
assets,
disabled,
onProductNameChange,
onThemeColorChange,
onUploadAsset,
onClearAsset,
}: {
productName: string;
productNameError: boolean;
themeColor: number | null;
assets: ReadonlyArray<BrandingAssetState>;
disabled: boolean;
onProductNameChange: (value: string) => void;
onThemeColorChange: (value: number | null) => void;
onUploadAsset: (kind: SetupBrandingAssetKind) => void;
onClearAsset: (kind: SetupBrandingAssetKind) => void;
}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.branding-step">
<StepHeader
title={i18n._(BRANDING_TITLE_DESCRIPTOR)}
body={i18n._(BRANDING_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.branding-step.step-header"
/>
<Input
label={i18n._(PRODUCT_NAME_LABEL_DESCRIPTOR)}
value={productName}
onChange={(event) => onProductNameChange(event.target.value)}
placeholder={i18n._(PRODUCT_NAME_PLACEHOLDER_DESCRIPTOR)}
maxLength={80}
disabled={disabled}
error={productNameError ? i18n._(PRODUCT_NAME_ERROR_DESCRIPTOR) : undefined}
data-step-focus="true"
data-flx="app.self-hosted-setup-wizard-gate.product-name-input"
/>
<ColorPickerField
label={i18n._(THEME_COLOR_LABEL_DESCRIPTOR)}
value={themeColor ?? DEFAULT_THEME_COLOR}
onChange={onThemeColorChange}
defaultValue={DEFAULT_THEME_COLOR}
isDefaultValue={themeColor === null}
onReset={() => onThemeColorChange(null)}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.theme-color-field"
/>
<div className={styles.assetSection} data-flx="app.self-hosted-setup-wizard-gate.asset-section">
<div className={styles.fieldLabel} data-flx="app.self-hosted-setup-wizard-gate.asset-section-label">
{i18n._(BRANDING_ASSETS_LABEL_DESCRIPTOR)}
</div>
{assets.map((asset) => (
<BrandingAssetRow
key={asset.kind}
asset={asset}
disabled={disabled}
onUpload={onUploadAsset}
onClear={onClearAsset}
data-flx="app.setup.setup-wizard-steps.branding-step.branding-asset-row"
/>
))}
</div>
</section>
);
},
);
export const RegistrationStep = observer(
({
mode,
disabled,
onChange,
}: {
mode: RegistrationMode;
disabled: boolean;
onChange: (mode: RegistrationMode) => void;
}) => {
const {i18n} = useLingui();
const options: ReadonlyArray<RadioOption<RegistrationMode>> = [
{
value: 'open',
name: i18n._(REGISTRATION_OPEN_NAME_DESCRIPTOR),
desc: i18n._(REGISTRATION_OPEN_DESC_DESCRIPTOR),
},
{
value: 'approval',
name: i18n._(REGISTRATION_APPROVAL_NAME_DESCRIPTOR),
desc: i18n._(REGISTRATION_APPROVAL_DESC_DESCRIPTOR),
},
{
value: 'closed',
name: i18n._(REGISTRATION_CLOSED_NAME_DESCRIPTOR),
desc: i18n._(REGISTRATION_CLOSED_DESC_DESCRIPTOR),
},
];
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.registration-step">
<StepHeader
title={i18n._(REGISTRATION_TITLE_DESCRIPTOR)}
body={i18n._(REGISTRATION_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.registration-step.step-header"
/>
<RadioGroup
options={options}
value={mode}
onChange={onChange}
disabled={disabled}
aria-label={i18n._(REGISTRATION_TITLE_DESCRIPTOR)}
data-flx="app.self-hosted-setup-wizard-gate.registration-radio-group"
/>
</section>
);
},
);
export const CommunityStep = observer(
({
singleCommunityEnabled,
singleCommunityName,
singleCommunityNameError,
directMessagesDisabled,
disabled,
onToggleSingleCommunity,
onSingleCommunityNameChange,
onToggleDirectMessages,
}: {
singleCommunityEnabled: boolean;
singleCommunityName: string;
singleCommunityNameError: boolean;
directMessagesDisabled: boolean;
disabled: boolean;
onToggleSingleCommunity: (value: boolean) => void;
onSingleCommunityNameChange: (value: string) => void;
onToggleDirectMessages: (value: boolean) => void;
}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.community-step">
<StepHeader
title={i18n._(COMMUNITY_TITLE_DESCRIPTOR)}
body={i18n._(COMMUNITY_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.community-step.step-header"
/>
<Switch
label={i18n._(SINGLE_COMMUNITY_LABEL_DESCRIPTOR)}
description={i18n._(SINGLE_COMMUNITY_DESC_DESCRIPTOR)}
value={singleCommunityEnabled}
onChange={onToggleSingleCommunity}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.single-community-switch"
/>
{singleCommunityEnabled && (
<Input
label={i18n._(SINGLE_COMMUNITY_NAME_LABEL_DESCRIPTOR)}
value={singleCommunityName}
onChange={(event) => onSingleCommunityNameChange(event.target.value)}
placeholder={i18n._(SINGLE_COMMUNITY_NAME_PLACEHOLDER_DESCRIPTOR)}
maxLength={100}
disabled={disabled}
error={singleCommunityNameError ? i18n._(SINGLE_COMMUNITY_NAME_ERROR_DESCRIPTOR) : undefined}
data-flx="app.self-hosted-setup-wizard-gate.single-community-name-input"
/>
)}
<Switch
label={i18n._(DISABLE_DM_LABEL_DESCRIPTOR)}
description={i18n._(DISABLE_DM_DESC_DESCRIPTOR)}
value={directMessagesDisabled}
onChange={onToggleDirectMessages}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.disable-dm-switch"
/>
</section>
);
},
);
export interface ServiceAvailability {
gif: boolean;
youtube: boolean;
bluesky: boolean;
}
export interface ServiceSelection {
gif: boolean;
youtube: boolean;
bluesky: boolean;
}
export interface MediaExpiryDraft {
enabled: boolean;
minSizeMb: string;
maxSizeMb: string;
maxEligibleSizeMb: string;
minLifetimeDays: string;
maxLifetimeDays: string;
curve: string;
renewThresholdDays: string;
renewWindowDays: string;
}
export type IntegrationStepKind = 'gif' | 'youtube' | 'email' | 'bluesky';
export type IntegrationSetupMode = 'later' | 'configure';
export interface ServiceIntegrationDraft {
gifMode: IntegrationSetupMode;
klipyApiKey: string;
youtubeMode: IntegrationSetupMode;
youtubeApiKey: string;
emailMode: IntegrationSetupMode;
emailEnabled: boolean;
emailFromEmail: string;
emailFromName: string;
smtpHost: string;
smtpPort: string;
smtpUsername: string;
smtpPassword: string;
smtpTlsMode: SmtpTlsModeChoice;
blueskyMode: IntegrationSetupMode;
blueskyEnabled: boolean;
blueskyClientName: string;
blueskyClientUri: string;
blueskyLogoUri: string;
blueskyTosUri: string;
blueskyPolicyUri: string;
blueskyKeyId: string;
blueskyPrivateKey: string;
}
const INTEGRATION_LATER_NAME_DESCRIPTOR = msg({
message: 'Set up later',
comment: 'Option for deferring an optional setup wizard integration.',
});
const INTEGRATION_LATER_DESC_DESCRIPTOR = msg({
message: 'Skip this now and configure it from the admin panel.',
comment: 'Description for deferring an optional setup wizard integration.',
});
const INTEGRATION_CONFIGURE_NAME_DESCRIPTOR = msg({
message: 'Configure now',
comment: 'Option for configuring an optional setup wizard integration.',
});
const INTEGRATION_CONFIGURE_DESC_DESCRIPTOR = msg({
message: 'Add the credentials during setup.',
comment: 'Description for configuring an optional setup wizard integration now.',
});
const GIF_SETUP_TITLE_DESCRIPTOR = msg({
message: 'GIF search',
comment: 'Setup wizard title for GIF integration credentials.',
});
const GIF_SETUP_BODY_DESCRIPTOR = msg({
message: 'Add a Klipy API key to enable GIF search at runtime.',
comment: 'Setup wizard body for GIF integration credentials.',
});
const YOUTUBE_SETUP_TITLE_DESCRIPTOR = msg({
message: 'YouTube previews',
comment: 'Setup wizard title for YouTube integration credentials.',
});
const YOUTUBE_SETUP_BODY_DESCRIPTOR = msg({
message: 'Add a YouTube Data API key to enrich YouTube links.',
comment: 'Setup wizard body for YouTube integration credentials.',
});
const EMAIL_SETUP_TITLE_DESCRIPTOR = msg({
message: 'Email delivery',
comment: 'Setup wizard title for SMTP integration credentials.',
});
const EMAIL_SETUP_BODY_DESCRIPTOR = msg({
message: 'Add SMTP credentials so the instance can send verification and notification email.',
comment: 'Setup wizard body for SMTP integration credentials.',
});
const BLUESKY_SETUP_TITLE_DESCRIPTOR = msg({
message: 'Bluesky OAuth',
comment: 'Setup wizard title for Bluesky integration credentials.',
});
const BLUESKY_SETUP_BODY_DESCRIPTOR = msg({
message: 'Add Bluesky OAuth client metadata and a signing key.',
comment: 'Setup wizard body for Bluesky integration credentials.',
});
const API_KEY_LABEL_DESCRIPTOR = msg({message: 'API key', comment: 'Label for an integration API key input.'});
const ENABLE_EMAIL_LABEL_DESCRIPTOR = msg({
message: 'Enable email delivery',
comment: 'Label for enabling SMTP email delivery in setup.',
});
const EMAIL_FROM_EMAIL_LABEL_DESCRIPTOR = msg({
message: 'From email',
comment: 'Label for SMTP from email input.',
});
const EMAIL_FROM_NAME_LABEL_DESCRIPTOR = msg({
message: 'From name',
comment: 'Label for SMTP from name input.',
});
const SMTP_HOST_LABEL_DESCRIPTOR = msg({message: 'SMTP host', comment: 'Label for SMTP host input.'});
const SMTP_PORT_LABEL_DESCRIPTOR = msg({message: 'Port', comment: 'Label for SMTP port input.'});
const SMTP_USERNAME_LABEL_DESCRIPTOR = msg({message: 'Username', comment: 'Label for SMTP username input.'});
const SMTP_PASSWORD_LABEL_DESCRIPTOR = msg({message: 'Password', comment: 'Label for SMTP password input.'});
const SMTP_TLS_MODE_LABEL_DESCRIPTOR = msg({
message: 'Encryption',
comment: 'Label for the choice of how the SMTP connection is encrypted.',
});
const SMTP_TLS_MODE_AUTO_NAME_DESCRIPTOR = msg({
message: 'Automatic',
comment: 'SMTP encryption option that picks the mode from the port.',
});
const SMTP_TLS_MODE_AUTO_DESC_DESCRIPTOR = msg({
message: 'Implicit TLS on port 465, required STARTTLS on any other port.',
comment: 'Description of the automatic SMTP encryption option.',
});
const SMTP_TLS_MODE_IMPLICIT_NAME_DESCRIPTOR = msg({
message: 'Implicit TLS',
comment: 'SMTP encryption option where the connection starts encrypted.',
});
const SMTP_TLS_MODE_IMPLICIT_DESC_DESCRIPTOR = msg({
message: 'The connection is encrypted from the start. Usually port 465.',
comment: 'Description of the implicit TLS SMTP encryption option.',
});
const SMTP_TLS_MODE_STARTTLS_NAME_DESCRIPTOR = msg({
message: 'STARTTLS',
comment: 'SMTP encryption option where a plain connection must upgrade with STARTTLS.',
});
const SMTP_TLS_MODE_STARTTLS_DESC_DESCRIPTOR = msg({
message: 'The connection must upgrade to TLS before signing in. Usually port 587.',
comment: 'Description of the required STARTTLS SMTP encryption option.',
});
const SMTP_TLS_MODE_OPPORTUNISTIC_NAME_DESCRIPTOR = msg({
message: 'STARTTLS when offered',
comment: 'SMTP encryption option where STARTTLS is used only if the server offers it.',
});
const SMTP_TLS_MODE_OPPORTUNISTIC_DESC_DESCRIPTOR = msg({
message: 'The connection upgrades to TLS only if the server offers it.',
comment: 'Description of the opportunistic STARTTLS SMTP encryption option.',
});
const SMTP_TLS_MODE_NONE_NAME_DESCRIPTOR = msg({
message: 'None',
comment: 'SMTP encryption option that never uses TLS.',
});
const SMTP_TLS_MODE_NONE_DESC_DESCRIPTOR = msg({
message: 'The connection is never encrypted. Only for a mail server on a network you trust.',
comment: 'Description of the SMTP encryption option that never uses TLS.',
});
const SMTP_TEST_DESCRIPTOR = msg({
message: 'Test SMTP',
comment: 'Button that validates SMTP credentials.',
});
const SMTP_TEST_OK_DESCRIPTOR = msg({
message: 'SMTP connection verified.',
comment: 'Success message after validating SMTP credentials.',
});
const SMTP_TEST_FAILED_DESCRIPTOR = msg({
message: 'SMTP validation failed.',
comment: 'Failure message after validating SMTP credentials when the server returns no error detail.',
});
const BLUESKY_ENABLED_LABEL_DESCRIPTOR = msg({
message: 'Enable Bluesky OAuth',
comment: 'Label for enabling Bluesky OAuth in setup.',
});
const BLUESKY_CLIENT_NAME_LABEL_DESCRIPTOR = msg({
message: 'Client name',
comment: 'Label for Bluesky OAuth client name input.',
});
const BLUESKY_CLIENT_URI_LABEL_DESCRIPTOR = msg({
message: 'Client URL',
comment: 'Label for Bluesky OAuth client URL input.',
});
const BLUESKY_LOGO_URI_LABEL_DESCRIPTOR = msg({
message: 'Logo URL',
comment: 'Label for Bluesky OAuth logo URL input.',
});
const BLUESKY_TOS_URI_LABEL_DESCRIPTOR = msg({
message: 'Terms URL',
comment: 'Label for Bluesky OAuth terms URL input.',
});
const BLUESKY_POLICY_URI_LABEL_DESCRIPTOR = msg({
message: 'Privacy URL',
comment: 'Label for Bluesky OAuth privacy URL input.',
});
const BLUESKY_KEY_ID_LABEL_DESCRIPTOR = msg({
message: 'Signing key ID',
comment: 'Label for Bluesky OAuth signing key ID input.',
});
const BLUESKY_PRIVATE_KEY_LABEL_DESCRIPTOR = msg({
message: 'Private key',
comment: 'Label for Bluesky OAuth private key input.',
});
export type SmtpTlsModeChoice = SmtpTlsMode | 'auto';
export function toSmtpTlsMode(choice: SmtpTlsModeChoice, port: number): SmtpTlsMode {
return choice === 'auto' ? resolveSmtpTlsMode(null, port) : choice;
}
function buildSmtpTlsModeOptions(
i18n: ReturnType<typeof useLingui>['i18n'],
): ReadonlyArray<RadioOption<SmtpTlsModeChoice>> {
return [
{
value: 'auto',
name: i18n._(SMTP_TLS_MODE_AUTO_NAME_DESCRIPTOR),
desc: i18n._(SMTP_TLS_MODE_AUTO_DESC_DESCRIPTOR),
},
{
value: SmtpTlsModes.IMPLICIT,
name: i18n._(SMTP_TLS_MODE_IMPLICIT_NAME_DESCRIPTOR),
desc: i18n._(SMTP_TLS_MODE_IMPLICIT_DESC_DESCRIPTOR),
},
{
value: SmtpTlsModes.STARTTLS,
name: i18n._(SMTP_TLS_MODE_STARTTLS_NAME_DESCRIPTOR),
desc: i18n._(SMTP_TLS_MODE_STARTTLS_DESC_DESCRIPTOR),
},
{
value: SmtpTlsModes.OPPORTUNISTIC,
name: i18n._(SMTP_TLS_MODE_OPPORTUNISTIC_NAME_DESCRIPTOR),
desc: i18n._(SMTP_TLS_MODE_OPPORTUNISTIC_DESC_DESCRIPTOR),
},
{
value: SmtpTlsModes.NONE,
name: i18n._(SMTP_TLS_MODE_NONE_NAME_DESCRIPTOR),
desc: i18n._(SMTP_TLS_MODE_NONE_DESC_DESCRIPTOR),
},
];
}
function buildIntegrationModeOptions(
i18n: ReturnType<typeof useLingui>['i18n'],
): ReadonlyArray<RadioOption<IntegrationSetupMode>> {
return [
{
value: 'later',
name: i18n._(INTEGRATION_LATER_NAME_DESCRIPTOR),
desc: i18n._(INTEGRATION_LATER_DESC_DESCRIPTOR),
},
{
value: 'configure',
name: i18n._(INTEGRATION_CONFIGURE_NAME_DESCRIPTOR),
desc: i18n._(INTEGRATION_CONFIGURE_DESC_DESCRIPTOR),
},
];
}
function getIntegrationMode(draft: ServiceIntegrationDraft, kind: IntegrationStepKind): IntegrationSetupMode {
switch (kind) {
case 'gif':
return draft.gifMode;
case 'youtube':
return draft.youtubeMode;
case 'email':
return draft.emailMode;
case 'bluesky':
return draft.blueskyMode;
}
}
function getIntegrationCopy(kind: IntegrationStepKind): {title: MessageDescriptor; body: MessageDescriptor} {
switch (kind) {
case 'gif':
return {title: GIF_SETUP_TITLE_DESCRIPTOR, body: GIF_SETUP_BODY_DESCRIPTOR};
case 'youtube':
return {title: YOUTUBE_SETUP_TITLE_DESCRIPTOR, body: YOUTUBE_SETUP_BODY_DESCRIPTOR};
case 'email':
return {title: EMAIL_SETUP_TITLE_DESCRIPTOR, body: EMAIL_SETUP_BODY_DESCRIPTOR};
case 'bluesky':
return {title: BLUESKY_SETUP_TITLE_DESCRIPTOR, body: BLUESKY_SETUP_BODY_DESCRIPTOR};
}
}
function parseSmtpPort(value: string): number | null {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : null;
}
const MEDIA_EXPIRY_REVEAL_TRANSITION: Transition = {
type: 'spring',
stiffness: 460,
damping: 40,
mass: 0.7,
};
export const MediaExpiryStep = observer(
({
draft,
disabled,
onDraftChange,
}: {
draft: MediaExpiryDraft;
disabled: boolean;
onDraftChange: (patch: Partial<MediaExpiryDraft>) => void;
}) => {
const {i18n} = useLingui();
const prefersReducedMotion = useReducedMotion();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.media-expiry-step">
<StepHeader
title={i18n._(MEDIA_EXPIRY_TITLE_DESCRIPTOR)}
body={i18n._(MEDIA_EXPIRY_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.step-header"
/>
<Switch
label={i18n._(MEDIA_EXPIRY_ENABLE_LABEL_DESCRIPTOR)}
description={i18n._(MEDIA_EXPIRY_ENABLE_DESC_DESCRIPTOR)}
value={draft.enabled}
onChange={(value) => onDraftChange({enabled: value})}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.media-expiry-switch"
/>
<AnimatePresence initial={false} data-flx="app.setup.setup-wizard-steps.media-expiry-step.animate-presence">
{draft.enabled && (
<motion.div
key="media-expiry-fields"
className={styles.collapsibleReveal}
initial={{height: 0, opacity: 0}}
animate={{height: 'auto', opacity: 1}}
exit={{height: 0, opacity: 0}}
transition={prefersReducedMotion ? {duration: 0} : MEDIA_EXPIRY_REVEAL_TRANSITION}
data-flx="app.self-hosted-setup-wizard-gate.media-expiry-reveal"
>
<div
className={styles.integrationFields}
data-flx="app.self-hosted-setup-wizard-gate.media-expiry-fields"
>
<div
className={styles.integrationGrid}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.integration-grid"
>
<Input
label={i18n._(MEDIA_MIN_SIZE_LABEL_DESCRIPTOR)}
inputMode="decimal"
value={draft.minSizeMb}
onChange={(event) => onDraftChange({minSizeMb: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change"
/>
<Input
label={i18n._(MEDIA_MAX_SIZE_LABEL_DESCRIPTOR)}
inputMode="decimal"
value={draft.maxSizeMb}
onChange={(event) => onDraftChange({maxSizeMb: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--2"
/>
</div>
<Input
label={i18n._(MEDIA_MAX_ELIGIBLE_SIZE_LABEL_DESCRIPTOR)}
inputMode="decimal"
value={draft.maxEligibleSizeMb}
onChange={(event) => onDraftChange({maxEligibleSizeMb: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--3"
/>
<div
className={styles.integrationGrid}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.integration-grid--2"
>
<Input
label={i18n._(MEDIA_MIN_LIFETIME_LABEL_DESCRIPTOR)}
inputMode="numeric"
value={draft.minLifetimeDays}
onChange={(event) => onDraftChange({minLifetimeDays: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--4"
/>
<Input
label={i18n._(MEDIA_MAX_LIFETIME_LABEL_DESCRIPTOR)}
inputMode="numeric"
value={draft.maxLifetimeDays}
onChange={(event) => onDraftChange({maxLifetimeDays: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--5"
/>
</div>
<div
className={styles.integrationGrid}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.integration-grid--3"
>
<Input
label={i18n._(MEDIA_CURVE_LABEL_DESCRIPTOR)}
inputMode="decimal"
value={draft.curve}
onChange={(event) => onDraftChange({curve: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--6"
/>
<Input
label={i18n._(MEDIA_RENEW_THRESHOLD_LABEL_DESCRIPTOR)}
inputMode="numeric"
value={draft.renewThresholdDays}
onChange={(event) => onDraftChange({renewThresholdDays: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--7"
/>
</div>
<Input
label={i18n._(MEDIA_RENEW_WINDOW_LABEL_DESCRIPTOR)}
inputMode="numeric"
value={draft.renewWindowDays}
onChange={(event) => onDraftChange({renewWindowDays: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.media-expiry-step.input.draft-change--8"
/>
</div>
</motion.div>
)}
</AnimatePresence>
</section>
);
},
);
export const IntegrationStep = observer(
({
kind,
draft,
disabled,
smtpTesting,
smtpTestResult,
onDraftChange,
onTestSmtp,
}: {
kind: IntegrationStepKind;
draft: ServiceIntegrationDraft;
disabled: boolean;
smtpTesting: boolean;
smtpTestResult: string | null;
onDraftChange: (patch: Partial<ServiceIntegrationDraft>) => void;
onTestSmtp: () => void;
}) => {
const {i18n} = useLingui();
const copy = getIntegrationCopy(kind);
const mode = getIntegrationMode(draft, kind);
const setMode = useCallback(
(next: IntegrationSetupMode) => {
switch (kind) {
case 'gif':
onDraftChange({gifMode: next});
break;
case 'youtube':
onDraftChange({youtubeMode: next});
break;
case 'email':
onDraftChange({emailMode: next});
break;
case 'bluesky':
onDraftChange({blueskyMode: next});
break;
}
},
[kind, onDraftChange],
);
return (
<section className={styles.step} data-flx={`app.self-hosted-setup-wizard-gate.integration-${kind}-step`}>
<StepHeader
title={i18n._(copy.title)}
body={i18n._(copy.body)}
data-flx="app.setup.setup-wizard-steps.integration-step.step-header"
/>
<RadioGroup
options={buildIntegrationModeOptions(i18n)}
value={mode}
onChange={setMode}
disabled={disabled}
aria-label={i18n._(copy.title)}
data-flx="app.setup.setup-wizard-steps.integration-step.radio-group.set-mode"
/>
{mode === 'configure' && (
<div className={styles.integrationFields} data-flx="app.self-hosted-setup-wizard-gate.integration-fields">
{kind === 'gif' && (
<Input
label={i18n._(API_KEY_LABEL_DESCRIPTOR)}
value={draft.klipyApiKey}
onChange={(event) => onDraftChange({klipyApiKey: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change"
/>
)}
{kind === 'youtube' && (
<Input
label={i18n._(API_KEY_LABEL_DESCRIPTOR)}
value={draft.youtubeApiKey}
onChange={(event) => onDraftChange({youtubeApiKey: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--2"
/>
)}
{kind === 'email' && (
<>
<Switch
label={i18n._(ENABLE_EMAIL_LABEL_DESCRIPTOR)}
value={draft.emailEnabled}
onChange={(value) => onDraftChange({emailEnabled: value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.switch.draft-change"
/>
<Input
label={i18n._(EMAIL_FROM_EMAIL_LABEL_DESCRIPTOR)}
value={draft.emailFromEmail}
onChange={(event) => onDraftChange({emailFromEmail: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--4"
/>
<Input
label={i18n._(EMAIL_FROM_NAME_LABEL_DESCRIPTOR)}
value={draft.emailFromName}
onChange={(event) => onDraftChange({emailFromName: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--5"
/>
<div
className={styles.integrationGrid}
data-flx="app.setup.setup-wizard-steps.integration-step.integration-grid"
>
<Input
label={i18n._(SMTP_HOST_LABEL_DESCRIPTOR)}
value={draft.smtpHost}
onChange={(event) => onDraftChange({smtpHost: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--6"
/>
<Input
label={i18n._(SMTP_PORT_LABEL_DESCRIPTOR)}
inputMode="numeric"
value={draft.smtpPort}
onChange={(event) => onDraftChange({smtpPort: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--7"
/>
</div>
<Input
label={i18n._(SMTP_USERNAME_LABEL_DESCRIPTOR)}
value={draft.smtpUsername}
onChange={(event) => onDraftChange({smtpUsername: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--8"
/>
<Input
label={i18n._(SMTP_PASSWORD_LABEL_DESCRIPTOR)}
type="password"
value={draft.smtpPassword}
onChange={(event) => onDraftChange({smtpPassword: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change.password--2"
/>
<div
className={styles.fieldLabel}
data-flx="app.setup.setup-wizard-steps.integration-step.smtp-tls-mode-label"
>
{i18n._(SMTP_TLS_MODE_LABEL_DESCRIPTOR)}
</div>
<RadioGroup
options={buildSmtpTlsModeOptions(i18n)}
value={draft.smtpTlsMode}
onChange={(value) => onDraftChange({smtpTlsMode: value})}
disabled={disabled}
aria-label={i18n._(SMTP_TLS_MODE_LABEL_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.integration-step.radio-group.smtp-tls-mode"
/>
<div
className={styles.integrationActionRow}
data-flx="app.setup.setup-wizard-steps.integration-step.integration-action-row"
>
<Button
variant="secondary"
small
submitting={smtpTesting}
disabled={
disabled ||
!draft.smtpHost.trim() ||
!parseSmtpPort(draft.smtpPort) ||
!draft.smtpUsername.trim() ||
!draft.smtpPassword.trim()
}
onClick={onTestSmtp}
data-flx="app.setup.setup-wizard-steps.integration-step.button.test-smtp"
>
{i18n._(SMTP_TEST_DESCRIPTOR)}
</Button>
{smtpTestResult && (
<span
className={styles.integrationStatus}
role="status"
data-flx="app.setup.setup-wizard-steps.integration-step.integration-status"
>
{smtpTestResult === 'ok'
? i18n._(SMTP_TEST_OK_DESCRIPTOR)
: smtpTestResult === 'failed'
? i18n._(SMTP_TEST_FAILED_DESCRIPTOR)
: smtpTestResult}
</span>
)}
</div>
</>
)}
{kind === 'bluesky' && (
<>
<Switch
label={i18n._(BLUESKY_ENABLED_LABEL_DESCRIPTOR)}
value={draft.blueskyEnabled}
onChange={(value) => onDraftChange({blueskyEnabled: value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.switch.draft-change--3"
/>
<Input
label={i18n._(BLUESKY_CLIENT_NAME_LABEL_DESCRIPTOR)}
value={draft.blueskyClientName}
onChange={(event) => onDraftChange({blueskyClientName: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--9"
/>
<Input
label={i18n._(BLUESKY_CLIENT_URI_LABEL_DESCRIPTOR)}
value={draft.blueskyClientUri}
onChange={(event) => onDraftChange({blueskyClientUri: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--10"
/>
<Input
label={i18n._(BLUESKY_LOGO_URI_LABEL_DESCRIPTOR)}
value={draft.blueskyLogoUri}
onChange={(event) => onDraftChange({blueskyLogoUri: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--11"
/>
<div
className={styles.integrationGrid}
data-flx="app.setup.setup-wizard-steps.integration-step.integration-grid--2"
>
<Input
label={i18n._(BLUESKY_TOS_URI_LABEL_DESCRIPTOR)}
value={draft.blueskyTosUri}
onChange={(event) => onDraftChange({blueskyTosUri: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--12"
/>
<Input
label={i18n._(BLUESKY_POLICY_URI_LABEL_DESCRIPTOR)}
value={draft.blueskyPolicyUri}
onChange={(event) => onDraftChange({blueskyPolicyUri: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--13"
/>
</div>
<Input
label={i18n._(BLUESKY_KEY_ID_LABEL_DESCRIPTOR)}
value={draft.blueskyKeyId}
onChange={(event) => onDraftChange({blueskyKeyId: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change--14"
/>
<Input
label={i18n._(BLUESKY_PRIVATE_KEY_LABEL_DESCRIPTOR)}
type="password"
value={draft.blueskyPrivateKey}
onChange={(event) => onDraftChange({blueskyPrivateKey: event.target.value})}
disabled={disabled}
data-flx="app.setup.setup-wizard-steps.integration-step.input.draft-change.password--3"
/>
</>
)}
</div>
)}
</section>
);
},
);
export const PushRelayConsentStep = observer(
({accepted, disabled, onChange}: {accepted: boolean; disabled: boolean; onChange: (value: boolean) => void}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-step">
<StepHeader
title={i18n._(PUSH_RELAY_TITLE_DESCRIPTOR)}
body={i18n._(PUSH_RELAY_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.step-header"
/>
<Switch
label={i18n._(PUSH_RELAY_ACCEPT_LABEL_DESCRIPTOR)}
description={i18n._(PUSH_RELAY_ACCEPT_DESC_DESCRIPTOR)}
value={accepted}
onChange={onChange}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.push-relay-consent-switch"
/>
<FocusRing data-flx="app.setup.setup-wizard-steps.push-relay-consent-step.focus-ring">
<a
className={styles.noticeLink}
href={PUSH_RELAY_NOTICE_URL}
target="_blank"
rel="noreferrer"
data-flx="app.self-hosted-setup-wizard-gate.push-relay-notice-link"
>
{i18n._(PUSH_RELAY_NOTICE_LINK_DESCRIPTOR)}
</a>
</FocusRing>
</section>
);
},
);
export const ServicesStep = observer(
({
available,
selection,
disabled,
onToggle,
}: {
available: ServiceAvailability;
selection: ServiceSelection;
disabled: boolean;
onToggle: (service: keyof ServiceSelection, value: boolean) => void;
}) => {
const {i18n} = useLingui();
const anyAvailable = available.gif || available.youtube || available.bluesky;
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.services-step">
<StepHeader
title={i18n._(SERVICES_TITLE_DESCRIPTOR)}
body={i18n._(SERVICES_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.services-step.step-header"
/>
{!anyAvailable && (
<p className={styles.emptyNote} data-flx="app.self-hosted-setup-wizard-gate.services-empty">
{i18n._(SERVICES_NONE_DESCRIPTOR)}
</p>
)}
{available.gif && (
<Switch
label={i18n._(SERVICE_GIF_LABEL_DESCRIPTOR)}
description={i18n._(SERVICE_GIF_DESC_DESCRIPTOR)}
value={selection.gif}
onChange={(value) => onToggle('gif', value)}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.service-gif-switch"
/>
)}
{available.youtube && (
<Switch
label={i18n._(SERVICE_YOUTUBE_LABEL_DESCRIPTOR)}
description={i18n._(SERVICE_YOUTUBE_DESC_DESCRIPTOR)}
value={selection.youtube}
onChange={(value) => onToggle('youtube', value)}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.service-youtube-switch"
/>
)}
{available.bluesky && (
<Switch
label={i18n._(SERVICE_BLUESKY_LABEL_DESCRIPTOR)}
description={i18n._(SERVICE_BLUESKY_DESC_DESCRIPTOR)}
value={selection.bluesky}
onChange={(value) => onToggle('bluesky', value)}
disabled={disabled}
data-flx="app.self-hosted-setup-wizard-gate.service-bluesky-switch"
/>
)}
</section>
);
},
);
export const PremiumStep = observer(
({mode, disabled, onChange}: {mode: PremiumMode; disabled: boolean; onChange: (mode: PremiumMode) => void}) => {
const {i18n} = useLingui();
const options: ReadonlyArray<RadioOption<PremiumMode>> = [
{
value: 'mirror',
name: i18n._(PREMIUM_MIRROR_NAME_DESCRIPTOR),
desc: i18n._(PREMIUM_MIRROR_DESC_DESCRIPTOR),
},
{
value: 'everyone',
name: i18n._(PREMIUM_EVERYONE_NAME_DESCRIPTOR),
desc: i18n._(PREMIUM_EVERYONE_DESC_DESCRIPTOR),
},
];
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.premium-step">
<StepHeader
title={i18n._(PREMIUM_TITLE_DESCRIPTOR)}
body={i18n._(PREMIUM_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.premium-step.step-header"
/>
<RadioGroup
options={options}
value={mode}
onChange={onChange}
disabled={disabled}
aria-label={i18n._(PREMIUM_TITLE_DESCRIPTOR)}
data-flx="app.self-hosted-setup-wizard-gate.premium-radio-group"
/>
</section>
);
},
);
interface SummaryRowProps {
label: string;
value: string;
}
const SummaryRow: React.FC<SummaryRowProps> = ({label, value}) => (
<div className={styles.summaryRow} data-flx="app.self-hosted-setup-wizard-gate.summary-row">
<span className={styles.summaryLabel} data-flx="app.self-hosted-setup-wizard-gate.summary-label">
{label}
</span>
<span className={styles.summaryValue} data-flx="app.self-hosted-setup-wizard-gate.summary-value">
{value}
</span>
</div>
);
export const FinishStep = observer(
({
productName,
accountIdentity,
tagStyle,
registrationMode,
singleCommunityEnabled,
directMessagesDisabled,
attachmentExpiryEnabled,
pushRelayConsentAccepted,
premiumMode,
submitError,
}: {
productName: string;
accountIdentity: AccountIdentityMode;
tagStyle: TagStyle;
registrationMode: RegistrationMode;
singleCommunityEnabled: boolean;
directMessagesDisabled: boolean;
attachmentExpiryEnabled: boolean;
pushRelayConsentAccepted: boolean;
premiumMode: PremiumMode;
submitError: string | null;
}) => {
const {i18n} = useLingui();
const registrationLabel =
registrationMode === 'open'
? i18n._(REGISTRATION_OPEN_NAME_DESCRIPTOR)
: registrationMode === 'approval'
? i18n._(REGISTRATION_APPROVAL_NAME_DESCRIPTOR)
: i18n._(REGISTRATION_CLOSED_NAME_DESCRIPTOR);
const premiumLabel =
premiumMode === 'mirror' ? i18n._(PREMIUM_MIRROR_NAME_DESCRIPTOR) : i18n._(PREMIUM_EVERYONE_NAME_DESCRIPTOR);
const onLabel = i18n._(SUMMARY_ON_DESCRIPTOR);
const offLabel = i18n._(SUMMARY_OFF_DESCRIPTOR);
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.finish-step">
<StepHeader
title={i18n._(FINISH_TITLE_DESCRIPTOR)}
body={i18n._(FINISH_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.finish-step.step-header"
/>
<div className={styles.summary} data-flx="app.self-hosted-setup-wizard-gate.summary">
<SummaryRow
label={i18n._(SUMMARY_PRODUCT_NAME_DESCRIPTOR)}
value={productName}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row"
/>
<SummaryRow
label={i18n._(SUMMARY_SIGN_IN_METHOD_DESCRIPTOR)}
value={
accountIdentity === AccountIdentityModes.USERNAME
? i18n._(SIGN_IN_METHOD_USERNAME_SUMMARY_DESCRIPTOR)
: i18n._(SIGN_IN_METHOD_EMAIL_NAME_DESCRIPTOR)
}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row.sign-in-method"
/>
{accountIdentity === AccountIdentityModes.EMAIL && (
<SummaryRow
label={i18n._(TAG_STYLE_TITLE_DESCRIPTOR)}
value={i18n._(
tagStyle === TagStyles.NONE ? TAG_STYLE_NONE_SUMMARY_DESCRIPTOR : TAG_STYLE_RANDOM_SUMMARY_DESCRIPTOR,
)}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row.username-style"
/>
)}
<SummaryRow
label={i18n._(SUMMARY_REGISTRATION_DESCRIPTOR)}
value={registrationLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--2"
/>
<SummaryRow
label={i18n._(SUMMARY_SINGLE_COMMUNITY_DESCRIPTOR)}
value={singleCommunityEnabled ? onLabel : offLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--3"
/>
<SummaryRow
label={i18n._(SUMMARY_DIRECT_MESSAGES_DESCRIPTOR)}
value={directMessagesDisabled ? offLabel : onLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--4"
/>
<SummaryRow
label={i18n._(SUMMARY_ATTACHMENT_EXPIRY_DESCRIPTOR)}
value={attachmentExpiryEnabled ? onLabel : offLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--5"
/>
<SummaryRow
label={i18n._(SUMMARY_PUSH_RELAY_DESCRIPTOR)}
value={
pushRelayConsentAccepted ? i18n._(SUMMARY_ACCEPTED_DESCRIPTOR) : i18n._(SUMMARY_NOT_ACCEPTED_DESCRIPTOR)
}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--6"
/>
<SummaryRow
label={i18n._(SUMMARY_PREMIUM_DESCRIPTOR)}
value={premiumLabel}
data-flx="app.setup.setup-wizard-steps.finish-step.summary-row--7"
/>
</div>
{submitError && (
<p className={styles.submitError} role="alert" data-flx="app.self-hosted-setup-wizard-gate.submit-error">
{submitError}
</p>
)}
</section>
);
},
);