feat(oauth): add OAuth callback URL display and copy functionality
This commit is contained in:
+11
-9
@@ -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)}
|
||||
>
|
||||
|
||||
Vendored
+51
-12
@@ -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))}
|
||||
>
|
||||
|
||||
+43
-1
@@ -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(/^\/+/, '')}`
|
||||
}
|
||||
+171
-1
@@ -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
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ export type AuthSettings = {
|
||||
EmailDomainRestrictionEnabled: boolean
|
||||
EmailAliasRestrictionEnabled: boolean
|
||||
EmailDomainWhitelist: string
|
||||
ServerAddress: string
|
||||
GitHubOAuthEnabled: boolean
|
||||
GitHubClientId: string
|
||||
GitHubClientSecret: string
|
||||
|
||||
Reference in New Issue
Block a user