/* * 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 . */ 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(null); const [authParams, setAuthParams] = useState(null); const [selectedScopes, setSelectedScopes] = useState | null>(null); const [selectedPermissions, setSelectedPermissions] = useState | null>(null); const [currentStep, setCurrentStep] = useState('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; scopes: Array; bot_public: boolean; bot?: { id: string; avatar: string | null; username?: string | null; } | null; } | null>(null); const [selectedGuildId, setSelectedGuildId] = useState(null); const [guildsLoading, setGuildsLoading] = useState(false); const [guildsError, setGuildsError] = useState(null); const [guilds, setGuilds] = useState | null>(null); const {setShowLogoSide} = useAuthLayoutContext(); useFluxerDocumentTitle(t`Authorize Application`); useLayoutEffect(() => { setShowLogoSide(false); return () => setShowLogoSide(true); }, [setShowLogoSide]); const openAccountSwitcher = useCallback(() => { modal(() => ); }, []); 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; scopes: Array; 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 = 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({ 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 ? No Manage Community permission : 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 = { 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 (
); } if (error || !authParams || validationError) { return (

Authorization Failed

{error ?? validationError ?? t`Invalid authorization request`}

); } if (successState) { return (

Bot added

{successState.guildName ? ( The bot has been added to {successState.guildName}. ) : ( The bot has been added. )}

); } if (currentStep === 'permissions') { return (
2 Permissions

Configure bot permissions

Choose what {clientLabel} can do in your community. Uncheck any permissions you don't want to grant.

{requestedPermissionKeys.map((perm) => { const option = botPermissionOptions.find((opt) => opt.id === perm); if (!option) return null; return (
togglePermission(perm)} onKeyDown={(e) => e.key === 'Enter' && togglePermission(perm)} role="button" tabIndex={0} > togglePermission(perm)} size="small" > {option.label}
); })}
{requestsAdmin && ( <>
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.
)} {permissionsAdjusted && ( <>
Removing permissions could limit the bot's features.
)}
{redirectHostname && (

You will be taken to{' '} {redirectHostname} {' '} after authorizing.

)}
); } return (
{needsPermissionsStep && (
1 Scopes 2 Permissions
)}

Authorization request

{clientLabel} wants to connect

{publicApp?.description ? ( publicApp.description ) : ( Review what this app is asking for before you continue. )}

{redirectHostname ? ( Will send you back to {redirectHostname} ) : ( botInviteWithoutRedirect && {t`Bot invite (no external redirect)`} )} {authParams.guildId && ( Target community: {authParams.guildId} )} {hasBotScope && {t`Bot scope requested`}}
{currentUser && ( <>
Signed in as
{currentUser.displayName || currentUser.username} {currentUser.username}#{currentUser.discriminator}
)}

Requested scopes

Turn off anything you're not comfortable with. Some features may stop working.

{scopes.length === 0 ? (
No specific scopes requested.
) : ( scopes.map((scope) => { const isLocked = scope === 'bot'; return (
toggleScope(scope)} disabled={isLocked} compact label={
{scope} {isLocked && {t`Required`}}
} description={{getScopeDescription(scope)}} />
); }) )}
{scopesAdjusted && (
Turning off scopes may prevent the app from working correctly.
)}
{hasBotScope &&
} {hasBotScope && (

Add bot to a community

Select a community where you have Manage Community{' '} permissions.