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:
@@ -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>
|
||||
|
||||
+40
@@ -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
|
||||
+76
-2
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user