fluxer/fluxer_app/src/components/pages/OAuthAuthorizePage.tsx
2026-02-21 16:57:09 +00:00

932 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 {getOAuth2ScopeDescription} from '@app/AppConstants';
import {modal} from '@app/actions/ModalActionCreators';
import AccountSwitcherModal from '@app/components/accounts/AccountSwitcherModal';
import {Select} from '@app/components/form/Select';
import {Switch} from '@app/components/form/Switch';
import {createGuildSelectComponents, type GuildSelectOption} from '@app/components/modals/shared/GuildSelectComponents';
import styles from '@app/components/pages/OAuthAuthorizePage.module.css';
import {BaseAvatar} from '@app/components/uikit/BaseAvatar';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Scroller} from '@app/components/uikit/Scroller';
import {Spinner} from '@app/components/uikit/Spinner';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {useAuthLayoutContext} from '@app/contexts/AuthLayoutContext';
import {Endpoints} from '@app/Endpoints';
import {useFluxerDocumentTitle} from '@app/hooks/useFluxerDocumentTitle';
import FluxerWordmarkMonochrome from '@app/images/fluxer-logo-wordmark-monochrome.svg?react';
import http from '@app/lib/HttpClient';
import {HttpError} from '@app/lib/HttpError';
import {Logger} from '@app/lib/Logger';
import UserStore from '@app/stores/UserStore';
import {getApiErrorCode, getApiErrorMessage} from '@app/utils/ApiErrorUtils';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import {formatBotPermissionsQuery, getAllBotPermissions} from '@app/utils/PermissionUtils';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {OAuth2Scope} from '@fluxer/constants/src/OAuth2Constants';
import {Trans, useLingui} from '@lingui/react/macro';
import {CheckCircleIcon} from '@phosphor-icons/react';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useMemo, useState} from 'react';
const logger = new Logger('OAuthAuthorizePage');
interface AuthorizeParams {
clientId: string;
redirectUri?: string | null;
scope: string;
state?: string | null;
permissions?: string | null;
guildId?: string | null;
prompt?: string | null;
responseType?: string | null;
codeChallenge?: string | null;
codeChallengeMethod?: string | null;
}
type FlowStep = 'scopes' | 'permissions';
const OAuthAuthorizePage: React.FC = observer(() => {
const {t, i18n} = useLingui();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState<'approve' | 'deny' | null>(null);
const [error, setError] = useState<string | null>(null);
const [authParams, setAuthParams] = useState<AuthorizeParams | null>(null);
const [selectedScopes, setSelectedScopes] = useState<Set<string> | null>(null);
const [selectedPermissions, setSelectedPermissions] = useState<Set<string> | null>(null);
const [currentStep, setCurrentStep] = useState<FlowStep>('scopes');
const [successState, setSuccessState] = useState<{guildName?: string | null} | null>(null);
const [publicApp, setPublicApp] = useState<{
id: string;
name: string;
icon: string | null;
description: string | null;
redirect_uris: Array<string>;
scopes: Array<string>;
bot_public: boolean;
bot?: {
id: string;
avatar: string | null;
username?: string | null;
} | null;
} | null>(null);
const [selectedGuildId, setSelectedGuildId] = useState<string | null>(null);
const [guildsLoading, setGuildsLoading] = useState(false);
const [guildsError, setGuildsError] = useState<string | null>(null);
const [guilds, setGuilds] = useState<Array<{
id: string;
name: string | null;
icon: string | null;
permissions?: string | null;
}> | null>(null);
const {setShowLogoSide} = useAuthLayoutContext();
useFluxerDocumentTitle(t`Authorize Application`);
useLayoutEffect(() => {
setShowLogoSide(false);
return () => setShowLogoSide(true);
}, [setShowLogoSide]);
const openAccountSwitcher = useCallback(() => {
modal(() => <AccountSwitcherModal />);
}, []);
const getScopeDescription = useCallback(
(scope: string) => {
return getOAuth2ScopeDescription(i18n, scope as OAuth2Scope) ?? scope;
},
[i18n],
);
useEffect(() => {
const qp = new URLSearchParams(window.location.search);
setLoading(true);
const clientId = qp.get('client_id') ?? '';
const redirectUri = qp.get('redirect_uri') ?? null;
if (!clientId) {
setError(t`Missing client_id`);
setLoading(false);
return;
}
const params: AuthorizeParams = {
clientId,
redirectUri,
scope: qp.get('scope') ?? '',
state: qp.get('state'),
permissions: qp.get('permissions'),
guildId: qp.get('guild_id'),
prompt: qp.get('prompt'),
responseType: qp.get('response_type') ?? 'code',
codeChallenge: qp.get('code_challenge'),
codeChallengeMethod: qp.get('code_challenge_method'),
};
setAuthParams(params);
const fetchPublicApp = async (clientIdValue: string) => {
try {
const resp = await http.get<{
id: string;
name: string;
icon: string | null;
description: string | null;
redirect_uris: Array<string>;
scopes: Array<string>;
bot_public: boolean;
}>({
url: Endpoints.OAUTH_PUBLIC_APPLICATION(clientIdValue),
rejectWithError: true,
});
setPublicApp(resp.body);
} catch (e) {
logger.error('Failed to fetch public application', e);
setError(t`Unknown application or it no longer exists.`);
} finally {
setLoading(false);
}
};
if (clientId) {
void fetchPublicApp(clientId);
} else {
setLoading(false);
}
}, []);
const scopes = useMemo(() => {
if (!authParams?.scope) return [];
return authParams.scope.split(/\s+/).filter(Boolean);
}, [authParams]);
const botPermissionOptions = useMemo(() => getAllBotPermissions(i18n), [i18n]);
const hasBotScope = useMemo(() => scopes.includes('bot'), [scopes]);
const isBotOnly = useMemo(() => {
return scopes.length === 1 && scopes[0] === 'bot';
}, [scopes]);
useEffect(() => {
if (!hasBotScope) return;
let cancelled = false;
setGuildsLoading(true);
setGuildsError(null);
const fetchGuilds = async () => {
try {
const resp = await http.get<
Array<{id: string; name: string | null; icon: string | null; permissions?: string | null}>
>({
url: Endpoints.USER_GUILDS_LIST,
rejectWithError: true,
});
if (cancelled) return;
setGuilds(resp.body);
} catch (e) {
if (cancelled) return;
logger.error('Failed to fetch user guilds', e);
const message = getApiErrorMessage(e) ?? t`Failed to load your communities.`;
setGuildsError(message);
setGuilds([]);
} finally {
if (!cancelled) {
setGuildsLoading(false);
}
}
};
void fetchGuilds();
return () => {
cancelled = true;
};
}, [hasBotScope]);
const guildsWithPermissions = useMemo(() => {
if (!guilds) return [];
return guilds.map((guild) => {
let permissionsValue = 0n;
try {
if (guild.permissions) {
permissionsValue = BigInt(guild.permissions);
}
} catch {
permissionsValue = 0n;
}
const canManage = (permissionsValue & Permissions.MANAGE_GUILD) === Permissions.MANAGE_GUILD;
return {
id: guild.id,
name: guild.name ?? t`Unknown community`,
icon: guild.icon ?? null,
canManage,
};
});
}, [guilds]);
const guildOptions: Array<GuildSelectOption> = useMemo(
() =>
guildsWithPermissions.map((guild) => ({
value: guild.id,
label: guild.name,
icon: guild.icon ?? null,
isDisabled: !guild.canManage,
})),
[guildsWithPermissions],
);
const guildLabelMap = useMemo(() => {
return new Map(guildOptions.map((option) => [option.value, option.label]));
}, [guildOptions]);
const manageableGuildOptions = useMemo(() => guildOptions.filter((option) => !option.isDisabled), [guildOptions]);
const requestedPermissionKeys = useMemo(() => {
if (!authParams?.permissions) return [];
try {
const bitfield = BigInt(authParams.permissions);
return botPermissionOptions
.filter((opt) => {
const flag = Permissions[opt.id as keyof typeof Permissions];
return flag != null && (bitfield & flag) === flag;
})
.map((opt) => opt.id);
} catch (err) {
logger.warn('Failed to parse requested permissions', err);
return [];
}
}, [authParams?.permissions, botPermissionOptions]);
useEffect(() => {
if (selectedScopes === null && scopes.length > 0) {
setSelectedScopes(new Set(scopes));
}
}, [scopes, selectedScopes]);
useEffect(() => {
if (selectedPermissions === null && requestedPermissionKeys.length > 0) {
setSelectedPermissions(new Set(requestedPermissionKeys));
}
}, [requestedPermissionKeys, selectedPermissions]);
useEffect(() => {
if (!hasBotScope) return;
if (authParams?.guildId) {
setSelectedGuildId(authParams.guildId);
return;
}
if (manageableGuildOptions.length > 0) {
setSelectedGuildId(manageableGuildOptions[0].value);
}
}, [authParams?.guildId, hasBotScope, manageableGuildOptions]);
const toggleScope = useCallback((scope: string) => {
if (scope === 'bot') return;
setSelectedScopes((prev) => {
const next = new Set(prev ?? []);
if (next.has(scope)) {
next.delete(scope);
} else {
next.add(scope);
}
return next;
});
}, []);
const togglePermission = useCallback((permissionId: string) => {
setSelectedPermissions((prev) => {
const next = new Set(prev ?? []);
if (next.has(permissionId)) {
next.delete(permissionId);
} else {
next.add(permissionId);
}
return next;
});
}, []);
const selectedScopeList = useMemo(() => Array.from(selectedScopes ?? []), [selectedScopes]);
const validationError = useMemo(() => {
if (!authParams) return null;
if (!isBotOnly && !authParams.redirectUri) {
return t`A redirect_uri is required when the bot scope is not the only scope.`;
}
if (!isBotOnly && publicApp && authParams.redirectUri) {
const allowed = publicApp.redirect_uris?.includes(authParams.redirectUri);
if (!allowed) {
return t`The provided redirect_uri is not registered for this application.`;
}
}
return null;
}, [authParams, isBotOnly, publicApp]);
const currentUser = UserStore.currentUser;
const redirectHostname = useMemo(() => {
if (!authParams?.redirectUri) return null;
try {
return new URL(authParams.redirectUri).hostname;
} catch (err) {
logger.warn('Invalid redirect_uri for authorize request', err);
return null;
}
}, [authParams?.redirectUri]);
const botInviteWithoutRedirect = useMemo(
() => hasBotScope && !authParams?.redirectUri,
[authParams?.redirectUri, hasBotScope],
);
const appName = publicApp?.name?.trim();
const clientLabel = appName || t`This application`;
const formattedPermissions = useMemo(() => {
if (!hasBotScope || !authParams?.permissions) return authParams?.permissions ?? undefined;
return formatBotPermissionsQuery(Array.from(selectedPermissions ?? []));
}, [authParams?.permissions, hasBotScope, selectedPermissions]);
const scopesAdjusted = useMemo(() => {
if (selectedScopes === null) return false;
return scopes.length > 0 && selectedScopes.size < scopes.length;
}, [scopes, selectedScopes]);
const permissionsAdjusted = useMemo(() => {
if (selectedPermissions === null) return false;
return requestedPermissionKeys.length > 0 && selectedPermissions.size < requestedPermissionKeys.length;
}, [requestedPermissionKeys, selectedPermissions]);
const requestsAdmin = useMemo(() => {
return requestedPermissionKeys.some((perm) => perm === 'ADMINISTRATOR');
}, [requestedPermissionKeys]);
const needsPermissionsStep = useMemo(() => {
return hasBotScope && requestedPermissionKeys.length > 0;
}, [hasBotScope, requestedPermissionKeys]);
const goToPermissions = useCallback(() => {
setCurrentStep('permissions');
}, []);
const goBack = useCallback(() => {
setCurrentStep('scopes');
}, []);
const guildSelectComponents = useMemo(
() =>
createGuildSelectComponents<GuildSelectOption>({
styles: {
optionRow: styles.guildOption,
valueRow: styles.guildValue,
avatar: styles.guildAvatar,
avatarPlaceholder: styles.guildAvatarPlaceholder,
label: styles.guildOptionLabel,
rowDisabled: styles.guildOptionDisabled,
notice: styles.guildOptionNotice,
},
getNotice: (_, disabled) => (disabled ? <Trans>No Manage Community permission</Trans> : null),
}),
[],
);
const onAuthorize = useCallback(async () => {
if (!authParams) return;
setSubmitting('approve');
try {
const scopeToSend = (selectedScopeList.length > 0 ? selectedScopeList : scopes).join(' ');
const guildIdToSend = hasBotScope ? (selectedGuildId ?? authParams.guildId) : authParams.guildId;
const body: Record<string, string | undefined> = {
response_type: authParams.responseType || 'code',
client_id: authParams.clientId,
scope: scopeToSend || authParams.scope,
};
if (authParams.redirectUri) body.redirect_uri = authParams.redirectUri;
if (authParams.state) body.state = authParams.state;
if (authParams.codeChallenge) body.code_challenge = authParams.codeChallenge;
if (authParams.codeChallengeMethod) body.code_challenge_method = authParams.codeChallengeMethod;
if (formattedPermissions && hasBotScope) body.permissions = formattedPermissions;
if (guildIdToSend) body.guild_id = guildIdToSend;
const resp = await http.post<{redirect_to: string}>({
url: Endpoints.OAUTH_CONSENT,
body,
rejectWithError: true,
});
if (botInviteWithoutRedirect) {
const guildName = guildIdToSend ? (guildLabelMap.get(guildIdToSend) ?? null) : null;
setSuccessState({guildName});
setSubmitting(null);
return;
}
if (resp.body?.redirect_to) {
window.location.href = resp.body.redirect_to;
} else {
setSubmitting(null);
setError(t`Authorization failed. Please try again.`);
}
} catch (e) {
logger.error('Authorization failed', e);
setSubmitting(null);
const httpError = e instanceof HttpError ? e : null;
const errorCode = getApiErrorCode(e);
if (httpError?.status === 400 && errorCode === 'BOT_ALREADY_IN_GUILD') {
const message = getApiErrorMessage(httpError);
setError(message ?? t`Bot is already in this community.`);
return;
}
setError(t`Authorization failed. Please try again.`);
}
}, [
authParams,
botInviteWithoutRedirect,
formattedPermissions,
guildLabelMap,
hasBotScope,
scopes,
selectedGuildId,
selectedScopeList,
]);
const onCancel = useCallback(() => {
if (!authParams) return;
setSubmitting('deny');
try {
const url = new URL(authParams.redirectUri ?? '/');
url.searchParams.set('error', 'access_denied');
if (authParams.state) {
url.searchParams.set('state', authParams.state);
}
window.location.href = url.toString();
} catch (err) {
logger.error('Failed to redirect on cancel', err);
setSubmitting(null);
setError(t`Invalid redirect_uri`);
}
}, [authParams]);
useEffect(() => {
if (!loading && authParams?.prompt === 'none' && !submitting && !successState) {
void onAuthorize();
}
}, [authParams?.prompt, loading, onAuthorize, submitting, successState]);
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spinner />
</div>
);
}
if (error || !authParams || validationError) {
return (
<div className={styles.errorContainer}>
<div className={styles.errorContent}>
<h1 className={styles.errorTitle}>
<Trans>Authorization Failed</Trans>
</h1>
<p className={styles.errorText}>{error ?? validationError ?? t`Invalid authorization request`}</p>
</div>
</div>
);
}
if (successState) {
return (
<div className={styles.page}>
<div className={styles.successScreen}>
<div className={styles.successIconCircle}>
<CheckCircleIcon weight="fill" className={styles.successIcon} />
</div>
<h1 className={styles.successTitle}>
<Trans>Bot added</Trans>
</h1>
<p className={styles.successSubtitle}>
{successState.guildName ? (
<Trans>
The bot has been added to <strong>{successState.guildName}</strong>.
</Trans>
) : (
<Trans>The bot has been added.</Trans>
)}
</p>
</div>
</div>
);
}
if (currentStep === 'permissions') {
return (
<div className={styles.page}>
<div className={styles.pageLogo}>
<FluxerWordmarkMonochrome className={styles.pageWordmark} />
</div>
<div className={styles.breadcrumbs}>
<button type="button" className={styles.breadcrumbStep} onClick={goBack}>
<span className={styles.breadcrumbNumber}>1</span>
<span className={styles.breadcrumbLabel}>
<Trans>Scopes</Trans>
</span>
</button>
<span className={styles.breadcrumbSeparator}></span>
<span className={clsx(styles.breadcrumbStep, styles.breadcrumbActive)}>
<span className={styles.breadcrumbNumber}>2</span>
<span className={styles.breadcrumbLabel}>
<Trans>Permissions</Trans>
</span>
</span>
</div>
<div className={styles.heroCard}>
<div className={styles.heroCopy}>
<h1 className={styles.heroTitle}>
<Trans>Configure bot permissions</Trans>
</h1>
<p className={styles.heroDescription}>
<Trans>
Choose what {clientLabel} can do in your community. Uncheck any permissions you don't want to grant.
</Trans>
</p>
</div>
</div>
<div className={styles.sectionDivider} />
<div className={styles.permissionScrollContainer}>
<Scroller key="oauth-permissions-scroller" className={styles.permissionScroller}>
<div className={styles.permissionList}>
{requestedPermissionKeys.map((perm) => {
const option = botPermissionOptions.find((opt) => opt.id === perm);
if (!option) return null;
return (
<div
key={perm}
className={styles.permissionRow}
onClick={() => togglePermission(perm)}
onKeyDown={(e) => e.key === 'Enter' && togglePermission(perm)}
role="button"
tabIndex={0}
>
<Checkbox
checked={selectedPermissions?.has(perm) ?? true}
onChange={() => togglePermission(perm)}
size="small"
>
<span className={styles.permissionLabel}>{option.label}</span>
</Checkbox>
</div>
);
})}
</div>
</Scroller>
</div>
{requestsAdmin && (
<>
<div className={styles.sectionDivider} />
<div className={styles.dangerNotice}>
<Trans>
This bot is requesting the Administrator permission. We do not recommend granting this to production
apps unless you fully trust the developer. Consider asking them to request a reduced set of permissions.
Close this page if you are unsure.
</Trans>
</div>
</>
)}
{permissionsAdjusted && (
<>
<div className={styles.sectionDivider} />
<div className={styles.caution}>
<Trans>Removing permissions could limit the bot's features.</Trans>
</div>
</>
)}
<div className={styles.sectionDivider} />
<div className={styles.actionSection}>
<div className={styles.actions}>
<div className={styles.actionButton}>
<Button type="button" variant="secondary" onClick={goBack} className={styles.actionButton}>
<Trans>Back</Trans>
</Button>
</div>
<div className={styles.actionButton}>
<Button
type="button"
submitting={submitting === 'approve'}
onClick={onAuthorize}
className={styles.actionButton}
>
<Trans>Authorize</Trans>
</Button>
</div>
</div>
{redirectHostname && (
<p className={styles.footerText}>
<Trans>
You will be taken to{' '}
<Tooltip text={authParams.redirectUri ?? ''} maxWidth="xl">
<strong>{redirectHostname}</strong>
</Tooltip>{' '}
after authorizing.
</Trans>
</p>
)}
</div>
</div>
);
}
return (
<div className={styles.page}>
<div className={styles.pageLogo}>
<FluxerWordmarkMonochrome className={styles.pageWordmark} />
</div>
{needsPermissionsStep && (
<div className={styles.breadcrumbs}>
<span className={clsx(styles.breadcrumbStep, styles.breadcrumbActive)}>
<span className={styles.breadcrumbNumber}>1</span>
<span className={styles.breadcrumbLabel}>
<Trans>Scopes</Trans>
</span>
</span>
<span className={styles.breadcrumbSeparator}></span>
<span className={styles.breadcrumbStep}>
<span className={styles.breadcrumbNumber}>2</span>
<span className={styles.breadcrumbLabel}>
<Trans>Permissions</Trans>
</span>
</span>
</div>
)}
<div className={styles.heroCard}>
<div className={styles.heroCopy}>
<p className={styles.eyebrow}>
<Trans>Authorization request</Trans>
</p>
<h1 className={styles.heroTitle}>
<Trans>{clientLabel} wants to connect</Trans>
</h1>
<p className={styles.heroDescription}>
{publicApp?.description ? (
publicApp.description
) : (
<Trans>Review what this app is asking for before you continue.</Trans>
)}
</p>
<div className={styles.chipRow}>
{redirectHostname ? (
<Tooltip text={authParams.redirectUri ?? ''} maxWidth="xl">
<span className={styles.chip}>
<Trans>Will send you back to</Trans> {redirectHostname}
</span>
</Tooltip>
) : (
botInviteWithoutRedirect && <span className={styles.chip}>{t`Bot invite (no external redirect)`}</span>
)}
{authParams.guildId && (
<span className={styles.chip}>
<Trans>Target community</Trans>: {authParams.guildId}
</span>
)}
{hasBotScope && <span className={styles.chip}>{t`Bot scope requested`}</span>}
</div>
</div>
</div>
<div className={styles.sectionDivider} />
{currentUser && (
<>
<div className={styles.userCard}>
<div className={styles.userDetails}>
<BaseAvatar
size={32}
avatarUrl={AvatarUtils.getUserAvatarURL(currentUser, false)}
shouldPlayAnimated={false}
/>
<div className={styles.userText}>
<div className={styles.userLabel}>
<Trans>Signed in as</Trans>
</div>
<div className={styles.userNameLine}>
<span className={styles.userName}>{currentUser.displayName || currentUser.username}</span>
<span className={styles.userTag}>
{currentUser.username}#{currentUser.discriminator}
</span>
</div>
</div>
</div>
<FocusRing offset={-2}>
<button type="button" className={styles.switchAccountLink} onClick={openAccountSwitcher}>
<Trans>Switch account</Trans>
</button>
</FocusRing>
</div>
<div className={styles.sectionDivider} />
</>
)}
<div className={styles.cardGrid}>
<div className={styles.panel}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>
<Trans>Requested scopes</Trans>
</h3>
<p className={styles.sectionDescription}>
<Trans>Turn off anything you're not comfortable with. Some features may stop working.</Trans>
</p>
</div>
<div className={styles.scopeList}>
{scopes.length === 0 ? (
<div className={styles.emptyState}>
<Trans>No specific scopes requested.</Trans>
</div>
) : (
scopes.map((scope) => {
const isLocked = scope === 'bot';
return (
<div key={scope} className={styles.scopeRow}>
<Switch
value={selectedScopes?.has(scope) ?? true}
onChange={() => toggleScope(scope)}
disabled={isLocked}
compact
label={
<div className={styles.scopeHeading}>
<span className={styles.scopeName}>{scope}</span>
{isLocked && <span className={styles.scopeChip}>{t`Required`}</span>}
</div>
}
description={<span className={styles.scopeDescription}>{getScopeDescription(scope)}</span>}
/>
</div>
);
})
)}
</div>
{scopesAdjusted && (
<div className={styles.caution}>
<Trans>Turning off scopes may prevent the app from working correctly.</Trans>
</div>
)}
</div>
{hasBotScope && <div className={styles.sectionDivider} />}
{hasBotScope && (
<div className={styles.panel}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>
<Trans>Add bot to a community</Trans>
</h3>
<p className={styles.sectionDescription}>
<Trans>
Select a community where you have <strong className={styles.textEmphasis}>Manage Community</strong>{' '}
permissions.
</Trans>
</p>
</div>
<Select
value={selectedGuildId || ''}
onChange={(value) => setSelectedGuildId((value as string) || null)}
options={guildOptions}
placeholder={guildsLoading ? t`Loading communities...` : t`Choose a community`}
components={guildSelectComponents}
isSearchable
disabled={guildOptions.length === 0 || guildsLoading}
/>
{guildsLoading && (
<div className={styles.sectionDescription}>
<Spinner />
</div>
)}
{guildsError && <div className={styles.sectionDescription}>{guildsError}</div>}
{!guildsLoading && manageableGuildOptions.length === 0 && (
<div className={styles.emptyState}>
<Trans>
No communities available with <strong className={styles.textEmphasis}>Manage Community</strong>{' '}
permissions.
</Trans>
</div>
)}
</div>
)}
{needsPermissionsStep && (
<>
<div className={styles.sectionDivider} />
<p className={styles.permissionsHint}>
<Trans>You'll configure which permissions the bot receives on the next screen.</Trans>
</p>
</>
)}
</div>
<div className={styles.sectionDivider} />
<div className={styles.actionSection}>
<div className={styles.actions}>
<div className={styles.actionButton}>
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={submitting === 'approve'}
className={styles.actionButton}
>
<Trans>Cancel</Trans>
</Button>
</div>
<div className={styles.actionButton}>
{needsPermissionsStep ? (
<Button type="button" onClick={goToPermissions} className={styles.actionButton}>
<Trans>Next</Trans>
</Button>
) : (
<Button
type="button"
submitting={submitting === 'approve'}
onClick={onAuthorize}
className={styles.actionButton}
>
<Trans>Authorize</Trans>
</Button>
)}
</div>
</div>
{redirectHostname && !needsPermissionsStep && (
<p className={styles.footerText}>
<Trans>
You will be taken to{' '}
<Tooltip text={authParams.redirectUri ?? ''} maxWidth="xl">
<strong>{redirectHostname}</strong>
</Tooltip>{' '}
after authorizing.
</Trans>
</p>
)}
</div>
</div>
);
});
export default OAuthAuthorizePage;