feat(oauth): add OAuth callback URL display and copy functionality

This commit is contained in:
CaIon
2026-07-08 12:50:57 +08:00
parent 57865fc1f8
commit 6a437a337d
15 changed files with 415 additions and 24 deletions
@@ -50,9 +50,13 @@ export function PresetSelector(props: PresetSelectorProps) {
// Auto-fill name, slug, icon, and field mappings immediately
props.form.setValue('name', preset.name, { shouldDirty: true })
props.form.setValue('slug', presetKey.toLowerCase().replace(/\s+/g, '-'), {
shouldDirty: true,
})
props.form.setValue(
'slug',
presetKey.toLowerCase().replaceAll(/\s+/g, '-'),
{
shouldDirty: true,
}
)
props.form.setValue('icon', preset.icon, { shouldDirty: true })
props.form.setValue('scopes', preset.scopes, { shouldDirty: true })
props.form.setValue('user_id_field', preset.user_id_field, {
@@ -111,12 +115,10 @@ export function PresetSelector(props: PresetSelectorProps) {
<div className='space-y-1.5'>
<Label>{t('Preset Template')}</Label>
<Select
items={[
...OAUTH_PRESETS.map((preset) => ({
value: preset.key,
label: preset.name,
})),
]}
items={OAUTH_PRESETS.map((preset) => ({
value: preset.key,
label: preset.name,
}))}
value={selectedPreset}
onValueChange={(v) => v !== null && handlePresetChange(v)}
>
@@ -18,10 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import { type Resolver, useForm } from 'react-hook-form'
import { type Resolver, useForm, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Form,
@@ -50,6 +52,7 @@ import {
SettingsSwitchContent,
SettingsSwitchItem,
} from '../../../components/settings-form-layout'
import { buildOAuthCallbackUrl } from '../../oauth-callback-url'
import {
useCreateProvider,
useUpdateProvider,
@@ -67,6 +70,7 @@ type ProviderFormDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
provider?: CustomOAuthProvider | null
serverAddress: string
}
const PROVIDER_FORM_ID = 'custom-oauth-provider-form'
@@ -102,6 +106,13 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
access_denied_message: '',
},
})
const watchedSlug = useWatch({ control: form.control, name: 'slug' })
const callbackPath = watchedSlug?.trim() || '{slug}'
const callbackUrl = buildOAuthCallbackUrl(
props.serverAddress,
callbackPath,
t('Site URL')
)
useEffect(() => {
if (props.open && props.provider) {
@@ -169,6 +180,12 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
}
const isPending = createProvider.isPending || updateProvider.isPending
let submitLabel = t('Create Provider')
if (isPending) {
submitLabel = t('Saving...')
} else if (isEditing) {
submitLabel = t('Update Provider')
}
return (
<Dialog
@@ -194,11 +211,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
{t('Cancel')}
</Button>
<Button type='submit' form={PROVIDER_FORM_ID} disabled={isPending}>
{isPending
? t('Saving...')
: isEditing
? t('Update Provider')
: t('Create Provider')}
{submitLabel}
</Button>
</>
}
@@ -211,6 +224,34 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
{/* Preset Selector (only for creating) */}
{!isEditing && <PresetSelector form={form} />}
<Alert>
<AlertTitle>{t('OAuth callback URL')}</AlertTitle>
<AlertDescription className='space-y-3 text-sm'>
<p>
{t(
'This callback URL updates from the slug field and is the value to register with your provider.'
)}
</p>
<div className='flex min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-muted-foreground shrink-0'>
{t('Authorization callback URL')}
</span>
<span className='flex min-w-0 items-center gap-2'>
<code className='bg-muted text-foreground min-w-0 rounded px-1.5 py-0.5 text-xs break-all'>
{callbackUrl}
</code>
<CopyButton
value={callbackUrl}
size='icon'
className='size-7'
tooltip={t('Copy callback URL')}
aria-label={t('Copy callback URL')}
/>
</span>
</div>
</AlertDescription>
</Alert>
{/* Basic Info */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
@@ -341,12 +382,10 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
<FormItem>
<FormLabel>{t('Auth Style')}</FormLabel>
<Select
items={[
...AUTH_STYLE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.labelKey),
})),
]}
items={AUTH_STYLE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.labelKey),
}))}
value={String(field.value)}
onValueChange={(val) => field.onChange(Number(val))}
>
@@ -19,18 +19,31 @@ For commercial licensing, please contact support@quantumnous.com
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { SettingsSection } from '../../components/settings-section'
import { buildOAuthCallbackUrl } from '../oauth-callback-url'
import { ProviderFormDialog } from './components/provider-form-dialog'
import { ProviderTable } from './components/provider-table'
import { useCustomOAuthProviders } from './hooks/use-custom-oauth-providers'
import type { CustomOAuthProvider } from './types'
export function CustomOAuthSection() {
type CustomOAuthSectionProps = {
serverAddress: string
}
export function CustomOAuthSection(props: CustomOAuthSectionProps) {
const { t } = useTranslation()
const { data: providers = [], isLoading } = useCustomOAuthProviders()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingProvider, setEditingProvider] =
useState<CustomOAuthProvider | null>(null)
const callbackFormat = buildOAuthCallbackUrl(
props.serverAddress,
'{slug}',
t('Site URL')
)
const handleCreate = () => {
setEditingProvider(null)
@@ -61,6 +74,34 @@ export function CustomOAuthSection() {
return (
<SettingsSection title={t('Custom OAuth Providers')}>
<Alert>
<AlertTitle>{t('Callback URL format')}</AlertTitle>
<AlertDescription className='space-y-3 text-sm'>
<p>
{t(
'Use this callback URL pattern when registering a custom OAuth provider.'
)}
</p>
<div className='flex min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-muted-foreground shrink-0'>
{t('OAuth callback URL')}
</span>
<span className='flex min-w-0 items-center gap-2'>
<code className='bg-muted text-foreground min-w-0 rounded px-1.5 py-0.5 text-xs break-all'>
{callbackFormat}
</code>
<CopyButton
value={callbackFormat}
size='icon'
className='size-7'
tooltip={t('Copy callback URL')}
aria-label={t('Copy callback URL')}
/>
</span>
</div>
</AlertDescription>
</Alert>
<ProviderTable
providers={providers}
onEdit={handleEdit}
@@ -71,6 +112,7 @@ export function CustomOAuthSection() {
open={dialogOpen}
onOpenChange={handleDialogChange}
provider={editingProvider}
serverAddress={props.serverAddress}
/>
</SettingsSection>
)
@@ -32,6 +32,7 @@ const defaultAuthSettings: AuthSettings = {
EmailDomainRestrictionEnabled: false,
EmailAliasRestrictionEnabled: false,
EmailDomainWhitelist: '',
ServerAddress: '',
GitHubOAuthEnabled: false,
GitHubClientId: '',
GitHubClientSecret: '',
@@ -0,0 +1,35 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program 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.
This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export function resolveOAuthSiteUrl(
serverAddress: string,
fallback: string
): string {
const normalized = serverAddress.trim().replace(/\/+$/, '')
return normalized || fallback
}
export function buildOAuthCallbackUrl(
serverAddress: string,
callbackPath: string,
fallback: string
): string {
const siteUrl = resolveOAuthSiteUrl(serverAddress, fallback)
return `${siteUrl}/oauth/${callbackPath.replace(/^\/+/, '')}`
}
@@ -18,12 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import axios from 'axios'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import * as z from 'zod'
import { CopyButton } from '@/components/copy-button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Form,
FormControl,
@@ -47,6 +50,10 @@ import {
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
buildOAuthCallbackUrl,
resolveOAuthSiteUrl,
} from './oauth-callback-url'
/**
* react-hook-form 7 treats dotted `name` strings as nested paths. To keep
@@ -117,6 +124,55 @@ type FlatOAuthDefaults = {
const oauthTabContentClassName =
'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2 [&>[data-slot=form-item]]:min-w-0 lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2'
type OAuthSetupGuideRow = {
label: ReactNode
value: string
copyLabel: string
}
type OAuthSetupGuideProps = {
title: string
description: ReactNode
rows: OAuthSetupGuideRow[]
children?: ReactNode
}
function OAuthSetupGuide(props: OAuthSetupGuideProps) {
return (
<Alert className='lg:col-span-2'>
<AlertTitle>{props.title}</AlertTitle>
<AlertDescription className='space-y-3 text-sm'>
<div>{props.description}</div>
<div className='space-y-2'>
{props.rows.map((row) => (
<div
key={`${String(row.label)}-${row.value}`}
className='flex min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between'
>
<span className='text-muted-foreground shrink-0'>
{row.label}
</span>
<span className='flex min-w-0 items-center gap-2'>
<code className='bg-muted text-foreground min-w-0 rounded px-1.5 py-0.5 text-xs break-all'>
{row.value}
</code>
<CopyButton
value={row.value}
size='icon'
className='size-7'
tooltip={row.copyLabel}
aria-label={row.copyLabel}
/>
</span>
</div>
))}
</div>
{props.children}
</AlertDescription>
</Alert>
)
}
const buildFormDefaults = (defaults: FlatOAuthDefaults): OAuthFormValues => ({
GitHubOAuthEnabled: defaults.GitHubOAuthEnabled,
GitHubClientId: defaults.GitHubClientId ?? '',
@@ -177,12 +233,34 @@ const normalizeFormValues = (values: OAuthFormValues): FlatOAuthDefaults => ({
type OAuthSectionProps = {
defaultValues: FlatOAuthDefaults
serverAddress: string
}
export function OAuthSection(props: OAuthSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [activeTab, setActiveTab] = useState('github')
const siteUrl = resolveOAuthSiteUrl(props.serverAddress, t('Site URL'))
const githubCallbackUrl = buildOAuthCallbackUrl(
props.serverAddress,
'github',
t('Site URL')
)
const discordCallbackUrl = buildOAuthCallbackUrl(
props.serverAddress,
'discord',
t('Site URL')
)
const oidcCallbackUrl = buildOAuthCallbackUrl(
props.serverAddress,
'oidc',
t('Site URL')
)
const linuxDOCallbackUrl = buildOAuthCallbackUrl(
props.serverAddress,
'linuxdo',
t('Site URL')
)
const formDefaults = useMemo(
() => buildFormDefaults(props.defaultValues),
@@ -306,6 +384,25 @@ export function OAuthSection(props: OAuthSectionProps) {
</TabsList>
<TabsContent value='github' className={oauthTabContentClassName}>
<OAuthSetupGuide
title={t('Setup guide')}
description={t(
'Set these values in the provider application before enabling login.'
)}
rows={[
{
label: t('Homepage URL'),
value: siteUrl,
copyLabel: t('Copy homepage URL'),
},
{
label: t('Authorization callback URL'),
value: githubCallbackUrl,
copyLabel: t('Copy callback URL'),
},
]}
/>
<FormField
control={form.control}
name='GitHubOAuthEnabled'
@@ -378,6 +475,25 @@ export function OAuthSection(props: OAuthSectionProps) {
</TabsContent>
<TabsContent value='discord' className={oauthTabContentClassName}>
<OAuthSetupGuide
title={t('Setup guide')}
description={t(
'Set these values in the provider application before enabling login.'
)}
rows={[
{
label: t('Homepage URL'),
value: siteUrl,
copyLabel: t('Copy homepage URL'),
},
{
label: t('Authorization callback URL'),
value: discordCallbackUrl,
copyLabel: t('Copy callback URL'),
},
]}
/>
<FormField
control={form.control}
name='discord.enabled'
@@ -450,6 +566,36 @@ export function OAuthSection(props: OAuthSectionProps) {
</TabsContent>
<TabsContent value='oidc' className={oauthTabContentClassName}>
<OAuthSetupGuide
title={t('Setup guide')}
description={
<div className='space-y-1'>
<p>
{t(
'Set these values in the provider application before enabling login.'
)}
</p>
<p>
{t(
'OIDC discovery can fill the endpoint fields automatically when the provider supports it.'
)}
</p>
</div>
}
rows={[
{
label: t('Homepage URL'),
value: siteUrl,
copyLabel: t('Copy homepage URL'),
},
{
label: t('Redirect URL'),
value: oidcCallbackUrl,
copyLabel: t('Copy redirect URL'),
},
]}
/>
<FormField
control={form.control}
name='oidc.enabled'
@@ -702,6 +848,30 @@ export function OAuthSection(props: OAuthSectionProps) {
</TabsContent>
<TabsContent value='linuxdo' className={oauthTabContentClassName}>
<OAuthSetupGuide
title={t('Setup guide')}
description={t(
'Set these values in the provider application before enabling login.'
)}
rows={[
{
label: t('Authorization callback URL'),
value: linuxDOCallbackUrl,
copyLabel: t('Copy callback URL'),
},
]}
>
<a
href='https://connect.linux.do/'
target='_blank'
rel='noreferrer'
className='text-primary inline-flex w-fit items-center gap-1 underline underline-offset-3 hover:no-underline'
>
{t('Manage your LinuxDO OAuth app')}
<ExternalLink className='size-3' aria-hidden='true' />
</a>
</OAuthSetupGuide>
<FormField
control={form.control}
name='LinuxDOOAuthEnabled'
@@ -47,6 +47,7 @@ const AUTH_SECTIONS = [
titleKey: 'OAuth Integrations',
build: (settings: AuthSettings) => (
<OAuthSection
serverAddress={settings.ServerAddress}
defaultValues={{
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GitHubClientId: settings.GitHubClientId,
@@ -115,7 +116,9 @@ const AUTH_SECTIONS = [
{
id: 'custom-oauth',
titleKey: 'Custom OAuth',
build: () => <CustomOAuthSection />,
build: (settings: AuthSettings) => (
<CustomOAuthSection serverAddress={settings.ServerAddress} />
),
},
] as const
+1
View File
@@ -129,6 +129,7 @@ export type AuthSettings = {
EmailDomainRestrictionEnabled: boolean
EmailAliasRestrictionEnabled: boolean
EmailDomainWhitelist: string
ServerAddress: string
GitHubOAuthEnabled: boolean
GitHubClientId: string
GitHubClientSecret: string