fix(oauth): align custom binding response fields in frontend (#6818)

* fix(oauth): align custom binding response fields in frontend

* fix(oauth): restore custom access policy guidance
This commit is contained in:
Seefs
2026-08-15 13:56:06 +08:00
committed by GitHub
parent 4442bb3028
commit 116255f076
14 changed files with 216 additions and 45 deletions
+2 -7
View File
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import type { CustomOAuthBinding } from '@/lib/oauth'
import type { LoginSession } from '@/stores/auth-store'
import type {
@@ -172,12 +173,6 @@ export async function revokeOtherLoginSessions(): Promise<ApiResponse> {
// Custom OAuth Binding APIs
// ============================================================================
export interface CustomOAuthBinding {
provider_id: string
provider_name: string
external_id?: string
}
/**
* Get current user's custom OAuth bindings
*/
@@ -192,7 +187,7 @@ export async function getSelfOAuthBindings(): Promise<
* Unbind a custom OAuth provider for current user
*/
export async function unbindCustomOAuth(
providerId: string
providerId: number
): Promise<ApiResponse> {
const res = await api.delete(`/api/user/oauth/bindings/${providerId}`)
return res.data
@@ -44,15 +44,13 @@ import { api } from '@/lib/api'
import {
buildDiscordOAuthUrl,
buildGitHubOAuthUrl,
indexCustomOAuthBindings,
buildLinuxDOOAuthUrl,
buildOIDCOAuthUrl,
type CustomOAuthBinding,
} from '@/lib/oauth'
import {
getSelfOAuthBindings,
unbindCustomOAuth,
type CustomOAuthBinding,
} from '../../api'
import { getSelfOAuthBindings, unbindCustomOAuth } from '../../api'
import type { UserProfile, BindingItem } from '../../types'
import { EmailBindDialog } from '../dialogs/email-bind-dialog'
import { TelegramBindDialog } from '../dialogs/telegram-bind-dialog'
@@ -112,6 +110,10 @@ export function AccountBindingsTab({
const customProviders = status?.custom_oauth_providers as
| CustomOAuthProviderInfo[]
| undefined
const customBindingsByProviderId = useMemo(
() => indexCustomOAuthBindings(customBindings),
[customBindings]
)
const fetchCustomBindings = useCallback(async () => {
if (!customProviders || customProviders.length === 0) return
@@ -474,9 +476,7 @@ export function AccountBindingsTab({
</p>
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
{customProviders.map((provider) => {
const binding = customBindings.find(
(b) => b.provider_id === String(provider.id)
)
const binding = customBindingsByProviderId.get(provider.id)
const isBound = !!binding
return (
<div
@@ -500,7 +500,7 @@ export function AccountBindingsTab({
</div>
<p className='text-muted-foreground truncate text-xs'>
{isBound
? binding?.external_id || t('Bound')
? binding?.provider_user_id || t('Bound')
: t('Not bound')}
</p>
</div>
@@ -0,0 +1,40 @@
/*
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 const ACCESS_POLICY_TEMPLATES = {
levelAndActive: `{
"logic": "and",
"conditions": [
{ "field": "trust_level", "op": "gte", "value": 2 },
{ "field": "active", "op": "eq", "value": true }
]
}`,
orgOrRole: `{
"logic": "or",
"conditions": [
{ "field": "org", "op": "eq", "value": "core" },
{ "field": "roles", "op": "contains", "value": "admin" }
]
}`,
} as const
export const ACCESS_DENIED_MESSAGE_TEMPLATES = {
level:
'Requires level {{required}}; your current level is {{current}} (field: {{field}}).',
org: 'Access is limited to approved organizations or roles. Organization: {{current.org}}; roles: {{current.roles}}.',
} as const
@@ -63,6 +63,10 @@ import {
type CustomOAuthProvider,
type CustomOAuthFormValues,
} from '../types'
import {
ACCESS_DENIED_MESSAGE_TEMPLATES,
ACCESS_POLICY_TEMPLATES,
} from './access-policy-templates'
import { DiscoveryButton } from './discovery-button'
import { PresetSelector } from './preset-selector'
@@ -603,6 +607,11 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
<FormDescription>
{t(
'Evaluate fields from the provider user info response. Conditions and nested groups use and/or logic.'
)}
</FormDescription>
<FormControl>
<JsonCodeEditor
value={field.value || ''}
@@ -618,9 +627,39 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
</FormControl>
<FormDescription>
{t(
'JSON-based access control rules. Leave empty to allow all users.'
'Supported operators: eq, ne, gt, gte, lt, lte, in, not_in, contains, not_contains, exists, not_exists. Leave empty to allow all users.'
)}
</FormDescription>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='xs'
onClick={() =>
form.setValue(
'access_policy',
ACCESS_POLICY_TEMPLATES.levelAndActive,
{ shouldDirty: true, shouldValidate: true }
)
}
>
{t('Fill template: level and active')}
</Button>
<Button
type='button'
variant='outline'
size='xs'
onClick={() =>
form.setValue(
'access_policy',
ACCESS_POLICY_TEMPLATES.orgOrRole,
{ shouldDirty: true, shouldValidate: true }
)
}
>
{t('Fill template: organization or role')}
</Button>
</div>
<FormMessage />
</FormItem>
)}
@@ -635,11 +674,46 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
<FormControl>
<Input
placeholder={t(
'Custom message shown when access is denied'
'e.g. Requires level {{required}}; your current level is {{current}}'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Available variables: {{provider}}, {{field}}, {{op}}, {{required}}, {{current}}, and paths such as {{current.roles}}.'
)}
</FormDescription>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='xs'
onClick={() =>
form.setValue(
'access_denied_message',
ACCESS_DENIED_MESSAGE_TEMPLATES.level,
{ shouldDirty: true }
)
}
>
{t('Fill template: level message')}
</Button>
<Button
type='button'
variant='outline'
size='xs'
onClick={() =>
form.setValue(
'access_denied_message',
ACCESS_DENIED_MESSAGE_TEMPLATES.org,
{ shouldDirty: true }
)
}
>
{t('Fill template: organization message')}
</Button>
</div>
<FormMessage />
</FormItem>
)}
+3 -9
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import type { PermissionCatalog } from '@/lib/admin-permissions'
import { api } from '@/lib/api'
import type { CustomOAuthBinding } from '@/lib/oauth'
import type {
User,
@@ -178,19 +179,12 @@ export async function getPermissionCatalog(): Promise<PermissionCatalog> {
// Admin Binding Management APIs
// ============================================================================
export interface OAuthBinding {
provider_id: string
provider_name: string
user_id?: number
external_id?: string
}
/**
* Get user's custom OAuth bindings (admin)
*/
export async function getUserOAuthBindings(
userId: number
): Promise<ApiResponse<OAuthBinding[]>> {
): Promise<ApiResponse<CustomOAuthBinding[]>> {
const res = await api.get(`/api/user/${userId}/oauth/bindings`)
return res.data
}
@@ -211,7 +205,7 @@ export async function adminClearUserBinding(
*/
export async function adminUnbindCustomOAuth(
userId: number,
providerId: string
providerId: number
): Promise<ApiResponse> {
const res = await api.delete(
`/api/user/${userId}/oauth/bindings/${providerId}`
@@ -45,13 +45,13 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { api } from '@/lib/api'
import { indexCustomOAuthBindings, type CustomOAuthBinding } from '@/lib/oauth'
import {
getUser,
getUserOAuthBindings,
adminClearUserBinding,
adminUnbindCustomOAuth,
type OAuthBinding,
} from '../../api'
import type { User } from '../../types'
@@ -68,7 +68,7 @@ interface BindingItem {
icon: React.ReactNode
value: string
type: 'builtin' | 'custom'
providerId?: string
providerId?: number
isBound: boolean
isEnabled: boolean
}
@@ -81,7 +81,7 @@ interface StatusInfo {
telegram_oauth?: boolean
linuxdo_oauth?: boolean
custom_oauth_providers?: Array<{
id: string
id: number
name: string
icon?: string
}>
@@ -162,7 +162,7 @@ function CustomProviderIcon(props: { iconUrl?: string }) {
export function UserBindingDialog(props: Props) {
const { t } = useTranslation()
const [user, setUser] = useState<User | null>(null)
const [oauthBindings, setOauthBindings] = useState<OAuthBinding[]>([])
const [oauthBindings, setOauthBindings] = useState<CustomOAuthBinding[]>([])
const [statusInfo, setStatusInfo] = useState<StatusInfo>({})
const [loading, setLoading] = useState(false)
const [showBoundOnly, setShowBoundOnly] = useState(true)
@@ -191,7 +191,7 @@ export function UserBindingDialog(props: Props) {
setUser(userRes.data)
}
if (oauthRes.success && oauthRes.data) {
setOauthBindings(oauthRes.data as OAuthBinding[])
setOauthBindings(oauthRes.data)
}
if (statusRes.success && statusRes.data) {
setStatusInfo(statusRes.data as StatusInfo)
@@ -236,37 +236,35 @@ export function UserBindingDialog(props: Props) {
})
}
const oauthBindingMap = new Map(
oauthBindings.map((b) => [String(b.provider_id), b])
)
const oauthBindingMap = indexCustomOAuthBindings(oauthBindings)
const customProviders = statusInfo.custom_oauth_providers || []
const seenProviderIds = new Set<string>()
const seenProviderIds = new Set<number>()
for (const provider of customProviders) {
seenProviderIds.add(String(provider.id))
const binding = oauthBindingMap.get(String(provider.id))
seenProviderIds.add(provider.id)
const binding = oauthBindingMap.get(provider.id)
items.push({
key: `oauth_${provider.id}`,
label: provider.name || provider.id,
label: provider.name || String(provider.id),
icon: <CustomProviderIcon iconUrl={provider.icon} />,
value: binding?.external_id || '',
value: binding?.provider_user_id || '',
type: 'custom',
providerId: String(provider.id),
providerId: provider.id,
isBound: !!binding,
isEnabled: true,
})
}
for (const binding of oauthBindings) {
if (!seenProviderIds.has(String(binding.provider_id))) {
if (!seenProviderIds.has(binding.provider_id)) {
items.push({
key: `oauth_${binding.provider_id}`,
label: binding.provider_name || binding.provider_id,
label: binding.provider_name || String(binding.provider_id),
icon: <Link2 className='h-4 w-4' />,
value: binding.external_id || '-',
value: binding.provider_user_id || '-',
type: 'custom',
providerId: String(binding.provider_id),
providerId: binding.provider_id,
isBound: true,
isEnabled: false,
})