mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
feat: add oidc provider settings page
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { ProviderList } from "@/components/oidc/provider-list"
|
||||
import { OidcForm } from "@/components/oidc/form"
|
||||
import { useOidcConfig } from "@/hooks/use-oidc-config"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import type {
|
||||
OidcConfigProvider,
|
||||
OidcProviderFormErrors,
|
||||
OidcProviderFormValues,
|
||||
SaveOidcConfigPayload,
|
||||
ValidateOidcConfigPayload,
|
||||
ValidateOidcConfigResponse,
|
||||
} from "@/types/oidc"
|
||||
import { DEFAULT_OIDC_FORM_VALUES } from "@/types/oidc"
|
||||
|
||||
function cloneDefaultFormValues(): OidcProviderFormValues {
|
||||
return { ...DEFAULT_OIDC_FORM_VALUES }
|
||||
}
|
||||
|
||||
function providerToFormValues(provider: OidcConfigProvider): OidcProviderFormValues {
|
||||
return {
|
||||
provider_id: provider.provider_id,
|
||||
enabled: provider.enabled,
|
||||
display_name: provider.display_name,
|
||||
config_url: provider.config_url,
|
||||
client_id: provider.client_id,
|
||||
client_secret: "",
|
||||
scopes: provider.scopes.join(","),
|
||||
redirect_uri: provider.redirect_uri,
|
||||
redirect_uri_dynamic: provider.redirect_uri_dynamic,
|
||||
claim_name: provider.claim_name,
|
||||
claim_prefix: provider.claim_prefix,
|
||||
role_policy: provider.role_policy,
|
||||
groups_claim: provider.groups_claim,
|
||||
email_claim: provider.email_claim,
|
||||
username_claim: provider.username_claim,
|
||||
}
|
||||
}
|
||||
|
||||
function trimOrEmpty(value: string) {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function parseScopes(scopes: string) {
|
||||
return scopes
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isAbsoluteHttpUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === "http:" || url.protocol === "https:"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(
|
||||
values: OidcProviderFormValues,
|
||||
options: {
|
||||
requireProviderId: boolean
|
||||
requireClientSecret: boolean
|
||||
},
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
const errors: OidcProviderFormErrors = {}
|
||||
const providerId = trimOrEmpty(values.provider_id)
|
||||
const configUrl = trimOrEmpty(values.config_url)
|
||||
const clientId = trimOrEmpty(values.client_id)
|
||||
const clientSecret = trimOrEmpty(values.client_secret)
|
||||
const redirectUri = trimOrEmpty(values.redirect_uri)
|
||||
const scopes = parseScopes(values.scopes)
|
||||
|
||||
if (options.requireProviderId) {
|
||||
if (!providerId) {
|
||||
errors.provider_id = t("Provider ID is required")
|
||||
} else if (!/^[A-Za-z0-9_-]+$/.test(providerId)) {
|
||||
errors.provider_id = t("Provider ID may only contain letters, numbers, underscores, and hyphens")
|
||||
}
|
||||
}
|
||||
|
||||
if (!configUrl) {
|
||||
errors.config_url = t("Configuration URL is required")
|
||||
} else if (!isAbsoluteHttpUrl(configUrl)) {
|
||||
errors.config_url = t("Configuration URL must be a valid HTTP or HTTPS URL")
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
errors.client_id = t("Client ID is required")
|
||||
}
|
||||
|
||||
if (options.requireClientSecret && !clientSecret) {
|
||||
errors.client_secret = t("Client Secret is required")
|
||||
}
|
||||
|
||||
if (scopes.length === 0 || !scopes.includes("openid")) {
|
||||
errors.scopes = t("Scopes must include openid")
|
||||
}
|
||||
|
||||
if (!values.redirect_uri_dynamic) {
|
||||
if (!redirectUri) {
|
||||
errors.redirect_uri = t("Redirect URI is required when dynamic redirect is disabled")
|
||||
} else if (!isAbsoluteHttpUrl(redirectUri)) {
|
||||
errors.redirect_uri = t("Redirect URI must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
function buildSavePayload(values: OidcProviderFormValues): SaveOidcConfigPayload {
|
||||
const payload: SaveOidcConfigPayload = {
|
||||
enabled: values.enabled,
|
||||
display_name: trimOrEmpty(values.display_name),
|
||||
config_url: trimOrEmpty(values.config_url),
|
||||
client_id: trimOrEmpty(values.client_id),
|
||||
scopes: parseScopes(values.scopes),
|
||||
redirect_uri: trimOrEmpty(values.redirect_uri),
|
||||
redirect_uri_dynamic: values.redirect_uri_dynamic,
|
||||
claim_name: trimOrEmpty(values.claim_name),
|
||||
claim_prefix: trimOrEmpty(values.claim_prefix),
|
||||
role_policy: trimOrEmpty(values.role_policy),
|
||||
groups_claim: trimOrEmpty(values.groups_claim),
|
||||
email_claim: trimOrEmpty(values.email_claim),
|
||||
username_claim: trimOrEmpty(values.username_claim),
|
||||
}
|
||||
|
||||
const clientSecret = trimOrEmpty(values.client_secret)
|
||||
if (clientSecret) {
|
||||
payload.client_secret = clientSecret
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function buildValidatePayload(values: OidcProviderFormValues): ValidateOidcConfigPayload {
|
||||
return {
|
||||
provider_id: trimOrEmpty(values.provider_id),
|
||||
config_url: trimOrEmpty(values.config_url),
|
||||
client_id: trimOrEmpty(values.client_id),
|
||||
client_secret: trimOrEmpty(values.client_secret),
|
||||
scopes: parseScopes(values.scopes),
|
||||
redirect_uri: trimOrEmpty(values.redirect_uri),
|
||||
redirect_uri_dynamic: values.redirect_uri_dynamic,
|
||||
}
|
||||
}
|
||||
|
||||
export default function OidcPage() {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { getOidcConfig, saveOidcConfig, deleteOidcConfig, validateOidcConfig } = useOidcConfig()
|
||||
|
||||
const [providers, setProviders] = useState<OidcConfigProvider[]>([])
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
|
||||
const [formValues, setFormValues] = useState<OidcProviderFormValues>(cloneDefaultFormValues)
|
||||
const [baselineValues, setBaselineValues] = useState<OidcProviderFormValues>(cloneDefaultFormValues)
|
||||
const [formErrors, setFormErrors] = useState<OidcProviderFormErrors>({})
|
||||
const [validateResult, setValidateResult] = useState<ValidateOidcConfigResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [restartRequired, setRestartRequired] = useState(false)
|
||||
const selectedProviderIdRef = useRef<string | null>(null)
|
||||
|
||||
const selectedProvider = useMemo(
|
||||
() => providers.find((provider) => provider.provider_id === selectedProviderId) ?? null,
|
||||
[providers, selectedProviderId],
|
||||
)
|
||||
const isCreateMode = selectedProvider === null
|
||||
const isReadOnly = selectedProvider?.editable === false || selectedProvider?.source === "env"
|
||||
const isDirty = JSON.stringify(formValues) !== JSON.stringify(baselineValues)
|
||||
|
||||
const applySelection = useCallback((providerId: string | null, nextProviders: OidcConfigProvider[]) => {
|
||||
const nextProvider = providerId
|
||||
? (nextProviders.find((provider) => provider.provider_id === providerId) ?? null)
|
||||
: null
|
||||
const nextFormValues = nextProvider ? providerToFormValues(nextProvider) : cloneDefaultFormValues()
|
||||
const nextProviderId = nextProvider?.provider_id ?? null
|
||||
|
||||
selectedProviderIdRef.current = nextProviderId
|
||||
setSelectedProviderId(nextProviderId)
|
||||
setFormValues(nextFormValues)
|
||||
setBaselineValues(nextFormValues)
|
||||
setFormErrors({})
|
||||
setValidateResult(null)
|
||||
}, [])
|
||||
|
||||
const loadProviders = useCallback(
|
||||
async (preferredProviderId?: string | null) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await getOidcConfig()
|
||||
const nextProviders = response?.providers ?? []
|
||||
setProviders(nextProviders)
|
||||
setRestartRequired(Boolean(response?.restart_required))
|
||||
|
||||
const candidateProviderId =
|
||||
preferredProviderId !== undefined
|
||||
? preferredProviderId
|
||||
: selectedProviderIdRef.current &&
|
||||
nextProviders.some((provider) => provider.provider_id === selectedProviderIdRef.current)
|
||||
? selectedProviderIdRef.current
|
||||
: (nextProviders[0]?.provider_id ?? null)
|
||||
|
||||
applySelection(candidateProviderId, nextProviders)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Failed to load OIDC providers"))
|
||||
setProviders([])
|
||||
applySelection(null, [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[applySelection, getOidcConfig, message, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadProviders()
|
||||
}, [loadProviders])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ""
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handler)
|
||||
return () => window.removeEventListener("beforeunload", handler)
|
||||
}, [isDirty])
|
||||
|
||||
const confirmDiscardChanges = useCallback(
|
||||
(onConfirm: () => void) => {
|
||||
dialog.warning({
|
||||
title: t("Discard Changes"),
|
||||
content: t("You have unsaved OIDC changes. Do you want to discard them?"),
|
||||
positiveText: t("Discard"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => {
|
||||
onConfirm()
|
||||
},
|
||||
})
|
||||
},
|
||||
[dialog, t],
|
||||
)
|
||||
|
||||
const requestSelection = useCallback(
|
||||
(providerId: string | null) => {
|
||||
const isSameSelection =
|
||||
(providerId === null && selectedProviderIdRef.current === null) || providerId === selectedProviderIdRef.current
|
||||
if (isSameSelection) return
|
||||
|
||||
const select = () => applySelection(providerId, providers)
|
||||
if (isDirty) {
|
||||
confirmDiscardChanges(select)
|
||||
return
|
||||
}
|
||||
select()
|
||||
},
|
||||
[applySelection, confirmDiscardChanges, isDirty, providers],
|
||||
)
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
<K extends keyof OidcProviderFormValues>(field: K, value: OidcProviderFormValues[K]) => {
|
||||
setFormValues((current) => ({ ...current, [field]: value }))
|
||||
setFormErrors((current) => {
|
||||
if (!current[field]) return current
|
||||
const next = { ...current }
|
||||
delete next[field]
|
||||
return next
|
||||
})
|
||||
setValidateResult(null)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isReadOnly) return
|
||||
|
||||
const errors = validateForm(
|
||||
formValues,
|
||||
{
|
||||
requireProviderId: true,
|
||||
requireClientSecret: isCreateMode,
|
||||
},
|
||||
t,
|
||||
)
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors)
|
||||
message.error(t("Please fix the form errors before saving"))
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const providerId = trimOrEmpty(formValues.provider_id)
|
||||
const response = await saveOidcConfig(providerId, buildSavePayload(formValues))
|
||||
|
||||
message.success(response?.message || t("OIDC provider saved"), {
|
||||
description: t("Changes will take effect after RustFS restarts"),
|
||||
})
|
||||
setRestartRequired(Boolean(response?.restart_required))
|
||||
await loadProviders(providerId)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Failed to save OIDC provider"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [formValues, isCreateMode, isReadOnly, loadProviders, message, saveOidcConfig, t])
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
if (!isCreateMode || isReadOnly) return
|
||||
|
||||
const errors = validateForm(
|
||||
formValues,
|
||||
{
|
||||
requireProviderId: true,
|
||||
requireClientSecret: true,
|
||||
},
|
||||
t,
|
||||
)
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors)
|
||||
message.error(t("Please fix the form errors before validating"))
|
||||
return
|
||||
}
|
||||
|
||||
setValidating(true)
|
||||
try {
|
||||
const response = await validateOidcConfig(buildValidatePayload(formValues))
|
||||
setValidateResult(response)
|
||||
|
||||
if (response.valid) {
|
||||
message.success(t("OIDC configuration validated successfully"))
|
||||
} else {
|
||||
message.error(response.message || t("Failed to validate OIDC configuration"))
|
||||
}
|
||||
} catch (error) {
|
||||
setValidateResult(null)
|
||||
message.error((error as Error).message || t("Failed to validate OIDC configuration"))
|
||||
} finally {
|
||||
setValidating(false)
|
||||
}
|
||||
}, [formValues, isCreateMode, isReadOnly, message, t, validateOidcConfig])
|
||||
|
||||
const performDelete = useCallback(async () => {
|
||||
if (!selectedProvider || isReadOnly) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await deleteOidcConfig(selectedProvider.provider_id)
|
||||
message.success(response?.message || t("OIDC provider deleted"), {
|
||||
description: t("Changes will take effect after RustFS restarts"),
|
||||
})
|
||||
setRestartRequired(Boolean(response?.restart_required))
|
||||
await loadProviders()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || t("Failed to delete OIDC provider"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [deleteOidcConfig, isReadOnly, loadProviders, message, selectedProvider, t])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedProvider || isReadOnly) return
|
||||
|
||||
dialog.error({
|
||||
title: t("Delete"),
|
||||
content: t("Are you sure you want to delete this OIDC provider?"),
|
||||
positiveText: t("Confirm"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: performDelete,
|
||||
})
|
||||
}, [dialog, isReadOnly, performDelete, selectedProvider, t])
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<h1 className="text-2xl font-bold">{t("OIDC Providers")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertTitle>{t("Environment Managed")}</AlertTitle>
|
||||
<AlertDescription className="space-y-1">
|
||||
<p>{t("Environment-managed providers are read-only")}</p>
|
||||
<p>{t("Changes will take effect after RustFS restarts")}</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
loading={loading}
|
||||
selectedProviderId={selectedProviderId}
|
||||
onAddProvider={() => requestSelection(null)}
|
||||
onSelectProvider={(providerId) => requestSelection(providerId)}
|
||||
/>
|
||||
|
||||
<OidcForm
|
||||
values={formValues}
|
||||
errors={formErrors}
|
||||
source={selectedProvider?.source}
|
||||
isCreateMode={isCreateMode}
|
||||
isReadOnly={isReadOnly}
|
||||
secretConfigured={selectedProvider?.client_secret_configured ?? false}
|
||||
restartRequired={restartRequired}
|
||||
isSaving={saving}
|
||||
isValidating={validating}
|
||||
validateResult={validateResult}
|
||||
onChange={handleFieldChange}
|
||||
onSave={handleSave}
|
||||
onValidate={handleValidate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import type {
|
||||
OidcConfigSource,
|
||||
OidcProviderFormErrors,
|
||||
OidcProviderFormValues,
|
||||
ValidateOidcConfigResponse,
|
||||
} from "@/types/oidc"
|
||||
|
||||
interface OidcFormProps {
|
||||
values: OidcProviderFormValues
|
||||
errors: OidcProviderFormErrors
|
||||
source?: OidcConfigSource
|
||||
isCreateMode: boolean
|
||||
isReadOnly: boolean
|
||||
secretConfigured: boolean
|
||||
restartRequired: boolean
|
||||
isSaving: boolean
|
||||
isValidating: boolean
|
||||
validateResult: ValidateOidcConfigResponse | null
|
||||
onChange: <K extends keyof OidcProviderFormValues>(field: K, value: OidcProviderFormValues[K]) => void
|
||||
onSave: () => void
|
||||
onValidate: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
function sourceLabel(source: OidcConfigSource | undefined, t: (key: string) => string) {
|
||||
if (!source) return null
|
||||
return source === "env" ? t("Environment Managed") : t("Persisted Configuration")
|
||||
}
|
||||
|
||||
export function OidcForm({
|
||||
values,
|
||||
errors,
|
||||
source,
|
||||
isCreateMode,
|
||||
isReadOnly,
|
||||
secretConfigured,
|
||||
restartRequired,
|
||||
isSaving,
|
||||
isValidating,
|
||||
validateResult,
|
||||
onChange,
|
||||
onSave,
|
||||
onValidate,
|
||||
onDelete,
|
||||
}: OidcFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const showRedirectUri = !values.redirect_uri_dynamic
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4 md:p-6">
|
||||
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isCreateMode
|
||||
? t("Add Provider")
|
||||
: values.display_name.trim() || values.provider_id || t("OIDC Provider")}
|
||||
</h2>
|
||||
{sourceLabel(source, t) ? <Badge variant="outline">{sourceLabel(source, t)}</Badge> : null}
|
||||
{!isCreateMode ? (
|
||||
<Badge variant={values.enabled ? "secondary" : "outline"}>
|
||||
{values.enabled ? t("Enabled") : t("Disabled")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isReadOnly
|
||||
? t("Environment-managed providers are read-only")
|
||||
: t("Changes will take effect after RustFS restarts")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isCreateMode ? (
|
||||
<Button type="button" variant="outline" onClick={onValidate} disabled={isValidating || isSaving}>
|
||||
{isValidating ? t("Validating...") : t("Validate")}
|
||||
</Button>
|
||||
) : null}
|
||||
{!isReadOnly ? (
|
||||
<Button type="button" onClick={onSave} disabled={isSaving || isValidating}>
|
||||
{isSaving ? t("Saving...") : t("Save")}
|
||||
</Button>
|
||||
) : null}
|
||||
{!isCreateMode && !isReadOnly ? (
|
||||
<Button type="button" variant="outline" onClick={onDelete} disabled={isSaving || isValidating}>
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{restartRequired ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Restart Required")}</AlertTitle>
|
||||
<AlertDescription>{t("Changes will take effect after RustFS restarts")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{validateResult ? (
|
||||
<Alert variant={validateResult.valid ? "default" : "destructive"}>
|
||||
<AlertTitle>
|
||||
{validateResult.valid
|
||||
? t("OIDC configuration validated successfully")
|
||||
: t("Failed to validate OIDC configuration")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="space-y-1">
|
||||
<p>{validateResult.message}</p>
|
||||
{validateResult.issuer ? (
|
||||
<p>
|
||||
<span className="font-medium">{t("Issuer")}:</span> {validateResult.issuer}
|
||||
</p>
|
||||
) : null}
|
||||
{validateResult.authorization_endpoint ? (
|
||||
<p>
|
||||
<span className="font-medium">{t("Authorization Endpoint")}:</span>{" "}
|
||||
{validateResult.authorization_endpoint}
|
||||
</p>
|
||||
) : null}
|
||||
{validateResult.token_endpoint ? (
|
||||
<p>
|
||||
<span className="font-medium">{t("Token Endpoint")}:</span> {validateResult.token_endpoint}
|
||||
</p>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="provider_id">{t("Provider ID")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="provider_id"
|
||||
value={values.provider_id}
|
||||
onChange={(event) => onChange("provider_id", event.target.value)}
|
||||
placeholder={t("Provider ID")}
|
||||
disabled={!isCreateMode}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>{t("Only letters, numbers, underscores, and hyphens are allowed.")}</FieldDescription>
|
||||
<FieldError>{errors.provider_id}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 rounded-md border p-3">
|
||||
<FieldLabel htmlFor="enabled">{t("Enabled")}</FieldLabel>
|
||||
<FieldContent className="gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={values.enabled}
|
||||
onCheckedChange={(checked) => onChange("enabled", checked)}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{values.enabled ? t("Enabled") : t("Disabled")}</span>
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="display_name">{t("Display Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="display_name"
|
||||
value={values.display_name}
|
||||
onChange={(event) => onChange("display_name", event.target.value)}
|
||||
placeholder={t("Display Name")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="config_url">{t("Configuration URL")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="config_url"
|
||||
value={values.config_url}
|
||||
onChange={(event) => onChange("config_url", event.target.value)}
|
||||
placeholder="https://idp.example.com/.well-known/openid-configuration"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.config_url}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="client_id">{t("Client ID")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={values.client_id}
|
||||
onChange={(event) => onChange("client_id", event.target.value)}
|
||||
placeholder={t("Client ID")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldError>{errors.client_id}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="client_secret">{t("Client Secret")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
value={values.client_secret}
|
||||
onChange={(event) => onChange("client_secret", event.target.value)}
|
||||
placeholder={isCreateMode ? t("Client Secret") : t("Leave empty to keep current secret")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{isCreateMode
|
||||
? t("Client secret is required when creating a provider.")
|
||||
: secretConfigured
|
||||
? t("Secret Configured")
|
||||
: t("No Secret Configured")}
|
||||
</FieldDescription>
|
||||
{!isCreateMode ? <FieldDescription>{t("Leave empty to keep current secret")}</FieldDescription> : null}
|
||||
<FieldError>{errors.client_secret}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field className="md:col-span-2">
|
||||
<FieldLabel htmlFor="scopes">{t("Scopes")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="scopes"
|
||||
value={values.scopes}
|
||||
onChange={(event) => onChange("scopes", event.target.value)}
|
||||
placeholder="openid,profile,email"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>{t("Use comma-separated scopes. The openid scope is required.")}</FieldDescription>
|
||||
<FieldError>{errors.scopes}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-start gap-3 rounded-md border p-3">
|
||||
<FieldLabel htmlFor="redirect_uri_dynamic">{t("Use Dynamic Redirect URI")}</FieldLabel>
|
||||
<FieldContent className="gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="redirect_uri_dynamic"
|
||||
checked={values.redirect_uri_dynamic}
|
||||
onCheckedChange={(checked) => onChange("redirect_uri_dynamic", checked)}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{values.redirect_uri_dynamic ? t("Enabled") : t("Disabled")}
|
||||
</span>
|
||||
</div>
|
||||
<FieldDescription>
|
||||
{values.redirect_uri_dynamic
|
||||
? t("Redirect URI will be resolved dynamically at runtime.")
|
||||
: t("Provide an absolute callback URL when dynamic redirect is disabled.")}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="redirect_uri">{t("Redirect URI")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="redirect_uri"
|
||||
value={values.redirect_uri}
|
||||
onChange={(event) => onChange("redirect_uri", event.target.value)}
|
||||
placeholder="https://rustfs.example.com/rustfs/admin/v3/oidc/callback/default"
|
||||
disabled={isReadOnly || !showRedirectUri}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{showRedirectUri
|
||||
? t("Must be an absolute callback URL.")
|
||||
: t("Dynamic redirect URI is enabled, so this field is optional.")}
|
||||
</FieldDescription>
|
||||
<FieldError>{errors.redirect_uri}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="claim_name">{t("Claim Name")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="claim_name"
|
||||
value={values.claim_name}
|
||||
onChange={(event) => onChange("claim_name", event.target.value)}
|
||||
placeholder={t("Claim Name")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="claim_prefix">{t("Claim Prefix")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="claim_prefix"
|
||||
value={values.claim_prefix}
|
||||
onChange={(event) => onChange("claim_prefix", event.target.value)}
|
||||
placeholder={t("Claim Prefix")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="role_policy">{t("Role Policy")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="role_policy"
|
||||
value={values.role_policy}
|
||||
onChange={(event) => onChange("role_policy", event.target.value)}
|
||||
placeholder={t("Role Policy")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{t("Optional. Leave empty to let the backend apply its default role mapping.")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="groups_claim">{t("Groups Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="groups_claim"
|
||||
value={values.groups_claim}
|
||||
onChange={(event) => onChange("groups_claim", event.target.value)}
|
||||
placeholder={t("Groups Claim")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email_claim">{t("Email Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="email_claim"
|
||||
value={values.email_claim}
|
||||
onChange={(event) => onChange("email_claim", event.target.value)}
|
||||
placeholder={t("Email Claim")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username_claim">{t("Username Claim")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="username_claim"
|
||||
value={values.username_claim}
|
||||
onChange={(event) => onChange("username_claim", event.target.value)}
|
||||
placeholder={t("Username Claim")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import { RiAddLine } from "@remixicon/react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { OidcConfigProvider } from "@/types/oidc"
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: OidcConfigProvider[]
|
||||
loading?: boolean
|
||||
selectedProviderId: string | null
|
||||
onAddProvider: () => void
|
||||
onSelectProvider: (providerId: string) => void
|
||||
}
|
||||
|
||||
function getSourceLabel(source: OidcConfigProvider["source"], t: (key: string) => string) {
|
||||
return source === "env" ? t("Environment Managed") : t("Persisted Configuration")
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
providers,
|
||||
loading = false,
|
||||
selectedProviderId,
|
||||
onAddProvider,
|
||||
onSelectProvider,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[28rem] flex-col rounded-md border">
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">{t("OIDC Providers")}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("View and manage persisted OIDC providers.")}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAddProvider}>
|
||||
<RiAddLine className="size-4" />
|
||||
{t("Add Provider")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" />
|
||||
<span>{t("Loading...")}</span>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t("No OIDC providers configured yet.")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{providers.map((provider) => {
|
||||
const selected = provider.provider_id === selectedProviderId
|
||||
const title = provider.display_name.trim() || provider.provider_id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.provider_id}
|
||||
type="button"
|
||||
onClick={() => onSelectProvider(provider.provider_id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 border-b px-4 py-3 text-left transition-colors last:border-b-0 hover:bg-muted/40",
|
||||
selected && "bg-muted/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{provider.provider_id}</div>
|
||||
</div>
|
||||
<Badge variant={provider.enabled ? "secondary" : "outline"}>
|
||||
{provider.enabled ? t("Enabled") : t("Disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{getSourceLabel(provider.source, t)}</Badge>
|
||||
<Badge variant="outline">
|
||||
{provider.client_secret_configured ? t("Secret Configured") : t("No Secret Configured")}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -98,6 +98,12 @@ export default [
|
||||
icon: "ri:secure-payment-line",
|
||||
isAdminOnly: true,
|
||||
},
|
||||
{
|
||||
label: "OIDC Providers",
|
||||
to: "/oidc",
|
||||
icon: "ri:fingerprint-line",
|
||||
isAdminOnly: true,
|
||||
},
|
||||
{
|
||||
label: "divider",
|
||||
key: "divider-2",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import { useApi } from "@/contexts/api-context"
|
||||
import type {
|
||||
DeleteOidcConfigResponse,
|
||||
OidcConfigResponse,
|
||||
SaveOidcConfigPayload,
|
||||
SaveOidcConfigResponse,
|
||||
ValidateOidcConfigPayload,
|
||||
ValidateOidcConfigResponse,
|
||||
} from "@/types/oidc"
|
||||
|
||||
export function useOidcConfig() {
|
||||
const api = useApi()
|
||||
|
||||
const getOidcConfig = useCallback(async () => {
|
||||
return (await api.get("/oidc/config")) as OidcConfigResponse
|
||||
}, [api])
|
||||
|
||||
const saveOidcConfig = useCallback(
|
||||
async (providerId: string, payload: SaveOidcConfigPayload) => {
|
||||
return (await api.put(`/oidc/config/${encodeURIComponent(providerId)}`, payload)) as SaveOidcConfigResponse
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
const deleteOidcConfig = useCallback(
|
||||
async (providerId: string) => {
|
||||
return (await api.delete(`/oidc/config/${encodeURIComponent(providerId)}`)) as DeleteOidcConfigResponse
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
const validateOidcConfig = useCallback(
|
||||
async (payload: ValidateOidcConfigPayload) => {
|
||||
return (await api.post("/oidc/validate", payload)) as ValidateOidcConfigResponse
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
return {
|
||||
getOidcConfig,
|
||||
saveOidcConfig,
|
||||
deleteOidcConfig,
|
||||
validateOidcConfig,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { hasConsoleScopes, type ConsolePolicy } from "@/lib/console-policy-parser"
|
||||
import { CONSOLE_SCOPES, PAGE_PERMISSIONS } from "@/lib/console-permissions"
|
||||
import { ADMIN_ONLY_PATHS, CONSOLE_SCOPES, PAGE_PERMISSIONS } from "@/lib/console-permissions"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useApiOptional } from "@/contexts/api-context"
|
||||
|
||||
@@ -73,6 +73,11 @@ export function PermissionsProvider({ children }: { children: React.ReactNode })
|
||||
(path: string) => {
|
||||
if (isAdmin) return true
|
||||
|
||||
const isAdminOnlyPath = ADMIN_ONLY_PATHS.some(
|
||||
(adminPath) => path === adminPath || path.startsWith(`${adminPath}/`),
|
||||
)
|
||||
if (isAdminOnlyPath) return false
|
||||
|
||||
let requiredScopes = PAGE_PERMISSIONS[path]
|
||||
if (!requiredScopes) {
|
||||
const match = Object.keys(PAGE_PERMISSIONS).find((key) => path.startsWith(key))
|
||||
|
||||
+64
-1
@@ -888,5 +888,68 @@
|
||||
"Access Denied": "Access Denied",
|
||||
"You do not have permission to access this page. This may be due to insufficient permissions or not being logged in.": "You do not have permission to access this page. This may be due to insufficient permissions or not being logged in.",
|
||||
"Back to Home": "Back to Home",
|
||||
"Your session has expired. Please log in again.": "Your session has expired. Please log in again."
|
||||
"Your session has expired. Please log in again.": "Your session has expired. Please log in again.",
|
||||
"Add Provider": "Add Provider",
|
||||
"Are you sure you want to delete this OIDC provider?": "Are you sure you want to delete this OIDC provider?",
|
||||
"Authorization Endpoint": "Authorization Endpoint",
|
||||
"Changes will take effect after RustFS restarts": "Changes will take effect after RustFS restarts",
|
||||
"Claim Name": "Claim Name",
|
||||
"Claim Prefix": "Claim Prefix",
|
||||
"Client ID": "Client ID",
|
||||
"Client Secret": "Client Secret",
|
||||
"Client Secret is required": "Client Secret is required",
|
||||
"Client secret is required when creating a provider.": "Client secret is required when creating a provider.",
|
||||
"Configuration URL": "Configuration URL",
|
||||
"Configuration URL is required": "Configuration URL is required",
|
||||
"Configuration URL must be a valid HTTP or HTTPS URL": "Configuration URL must be a valid HTTP or HTTPS URL",
|
||||
"Discard": "Discard",
|
||||
"Discard Changes": "Discard Changes",
|
||||
"Display Name": "Display Name",
|
||||
"Dynamic redirect URI is enabled, so this field is optional.": "Dynamic redirect URI is enabled, so this field is optional.",
|
||||
"Email Claim": "Email Claim",
|
||||
"Environment Managed": "Environment Managed",
|
||||
"Environment-managed providers are read-only": "Environment-managed providers are read-only",
|
||||
"Failed to delete OIDC provider": "Failed to delete OIDC provider",
|
||||
"Failed to load OIDC providers": "Failed to load OIDC providers",
|
||||
"Failed to save OIDC provider": "Failed to save OIDC provider",
|
||||
"Failed to validate OIDC configuration": "Failed to validate OIDC configuration",
|
||||
"Groups Claim": "Groups Claim",
|
||||
"Issuer": "Issuer",
|
||||
"Leave empty to keep current secret": "Leave empty to keep current secret",
|
||||
"Loading...": "Loading...",
|
||||
"Must be an absolute callback URL.": "Must be an absolute callback URL.",
|
||||
"No OIDC providers configured yet.": "No OIDC providers configured yet.",
|
||||
"No Secret Configured": "No Secret Configured",
|
||||
"OIDC Provider": "OIDC Provider",
|
||||
"OIDC Providers": "OIDC Providers",
|
||||
"OIDC configuration validated successfully": "OIDC configuration validated successfully",
|
||||
"OIDC provider deleted": "OIDC provider deleted",
|
||||
"OIDC provider saved": "OIDC provider saved",
|
||||
"Only letters, numbers, underscores, and hyphens are allowed.": "Only letters, numbers, underscores, and hyphens are allowed.",
|
||||
"Optional. Leave empty to let the backend apply its default role mapping.": "Optional. Leave empty to let the backend apply its default role mapping.",
|
||||
"Persisted Configuration": "Persisted Configuration",
|
||||
"Please fix the form errors before saving": "Please fix the form errors before saving",
|
||||
"Please fix the form errors before validating": "Please fix the form errors before validating",
|
||||
"Provide an absolute callback URL when dynamic redirect is disabled.": "Provide an absolute callback URL when dynamic redirect is disabled.",
|
||||
"Provider ID": "Provider ID",
|
||||
"Provider ID is required": "Provider ID is required",
|
||||
"Provider ID may only contain letters, numbers, underscores, and hyphens": "Provider ID may only contain letters, numbers, underscores, and hyphens",
|
||||
"Redirect URI": "Redirect URI",
|
||||
"Redirect URI is required when dynamic redirect is disabled": "Redirect URI is required when dynamic redirect is disabled",
|
||||
"Redirect URI must be an absolute HTTP or HTTPS URL": "Redirect URI must be an absolute HTTP or HTTPS URL",
|
||||
"Redirect URI will be resolved dynamically at runtime.": "Redirect URI will be resolved dynamically at runtime.",
|
||||
"Restart Required": "Restart Required",
|
||||
"Role Policy": "Role Policy",
|
||||
"Saving...": "Saving...",
|
||||
"Scopes": "Scopes",
|
||||
"Scopes must include openid": "Scopes must include openid",
|
||||
"Secret Configured": "Secret Configured",
|
||||
"Token Endpoint": "Token Endpoint",
|
||||
"Use Dynamic Redirect URI": "Use Dynamic Redirect URI",
|
||||
"Use comma-separated scopes. The openid scope is required.": "Use comma-separated scopes. The openid scope is required.",
|
||||
"Username Claim": "Username Claim",
|
||||
"Validate": "Validate",
|
||||
"Validating...": "Validating...",
|
||||
"View and manage persisted OIDC providers.": "View and manage persisted OIDC providers.",
|
||||
"You have unsaved OIDC changes. Do you want to discard them?": "You have unsaved OIDC changes. Do you want to discard them?"
|
||||
}
|
||||
|
||||
+64
-1
@@ -893,5 +893,68 @@
|
||||
"submit": "提交",
|
||||
"success": "成功",
|
||||
"username length cannot be less than 8 characters and greater than 16 characters": "用户名长度不能少于8个字符且不能大于16个字符",
|
||||
"waiting": "等待中"
|
||||
"waiting": "等待中",
|
||||
"Add Provider": "新增 Provider",
|
||||
"Are you sure you want to delete this OIDC provider?": "确定要删除这个 OIDC provider 吗?",
|
||||
"Authorization Endpoint": "授权端点",
|
||||
"Changes will take effect after RustFS restarts": "变更将在 RustFS 重启后生效",
|
||||
"Claim Name": "声明名称",
|
||||
"Claim Prefix": "声明前缀",
|
||||
"Client ID": "客户端 ID",
|
||||
"Client Secret": "客户端密钥",
|
||||
"Client Secret is required": "客户端密钥为必填项",
|
||||
"Client secret is required when creating a provider.": "创建 provider 时必须填写客户端密钥。",
|
||||
"Configuration URL": "配置 URL",
|
||||
"Configuration URL is required": "配置 URL 为必填项",
|
||||
"Configuration URL must be a valid HTTP or HTTPS URL": "配置 URL 必须是有效的 HTTP 或 HTTPS 地址",
|
||||
"Discard": "放弃",
|
||||
"Discard Changes": "放弃更改",
|
||||
"Display Name": "显示名称",
|
||||
"Dynamic redirect URI is enabled, so this field is optional.": "已启用动态重定向 URI,因此该字段为可选项。",
|
||||
"Email Claim": "邮箱声明",
|
||||
"Environment Managed": "环境变量管理",
|
||||
"Environment-managed providers are read-only": "由环境变量管理的 provider 为只读",
|
||||
"Failed to delete OIDC provider": "删除 OIDC provider 失败",
|
||||
"Failed to load OIDC providers": "加载 OIDC providers 失败",
|
||||
"Failed to save OIDC provider": "保存 OIDC provider 失败",
|
||||
"Failed to validate OIDC configuration": "OIDC 配置校验失败",
|
||||
"Groups Claim": "分组声明",
|
||||
"Issuer": "签发者",
|
||||
"Leave empty to keep current secret": "留空表示保留当前密钥",
|
||||
"Loading...": "加载中...",
|
||||
"Must be an absolute callback URL.": "必须填写绝对回调 URL。",
|
||||
"No OIDC providers configured yet.": "当前还没有配置任何 OIDC provider。",
|
||||
"No Secret Configured": "未配置密钥",
|
||||
"OIDC Provider": "OIDC Provider",
|
||||
"OIDC Providers": "OIDC Providers",
|
||||
"OIDC configuration validated successfully": "OIDC 配置校验成功",
|
||||
"OIDC provider deleted": "OIDC provider 已删除",
|
||||
"OIDC provider saved": "OIDC provider 已保存",
|
||||
"Only letters, numbers, underscores, and hyphens are allowed.": "仅允许字母、数字、下划线和连字符。",
|
||||
"Optional. Leave empty to let the backend apply its default role mapping.": "可选。留空时由后端应用默认角色映射。",
|
||||
"Persisted Configuration": "持久化配置",
|
||||
"Please fix the form errors before saving": "请先修正表单错误再保存",
|
||||
"Please fix the form errors before validating": "请先修正表单错误再校验",
|
||||
"Provide an absolute callback URL when dynamic redirect is disabled.": "关闭动态重定向时必须提供绝对回调 URL。",
|
||||
"Provider ID": "Provider ID",
|
||||
"Provider ID is required": "Provider ID 为必填项",
|
||||
"Provider ID may only contain letters, numbers, underscores, and hyphens": "Provider ID 只能包含字母、数字、下划线和连字符",
|
||||
"Redirect URI": "重定向 URI",
|
||||
"Redirect URI is required when dynamic redirect is disabled": "关闭动态重定向时必须填写重定向 URI",
|
||||
"Redirect URI must be an absolute HTTP or HTTPS URL": "重定向 URI 必须是绝对的 HTTP 或 HTTPS 地址",
|
||||
"Redirect URI will be resolved dynamically at runtime.": "重定向 URI 将在运行时动态解析。",
|
||||
"Restart Required": "需要重启",
|
||||
"Role Policy": "角色策略",
|
||||
"Saving...": "保存中...",
|
||||
"Scopes": "作用域",
|
||||
"Scopes must include openid": "Scopes 必须包含 openid",
|
||||
"Secret Configured": "已配置密钥",
|
||||
"Token Endpoint": "令牌端点",
|
||||
"Use Dynamic Redirect URI": "使用动态重定向 URI",
|
||||
"Use comma-separated scopes. The openid scope is required.": "使用逗号分隔多个 scope,且必须包含 openid。",
|
||||
"Username Claim": "用户名声明",
|
||||
"Validate": "校验",
|
||||
"Validating...": "校验中...",
|
||||
"View and manage persisted OIDC providers.": "查看并管理持久化的 OIDC providers。",
|
||||
"You have unsaved OIDC changes. Do you want to discard them?": "你有未保存的 OIDC 更改,是否要放弃?"
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ export const CONSOLE_SCOPES = {
|
||||
VIEW_TIERED_STORAGE: "console:TieredStorage",
|
||||
VIEW_EVENT_DESTINATIONS: "console:EventDestinations",
|
||||
VIEW_SSE_SETTINGS: "console:SSESettings",
|
||||
VIEW_OIDC_SETTINGS: "console:OIDCSettings",
|
||||
VIEW_LICENSE: "console:License",
|
||||
} as const
|
||||
|
||||
export const ADMIN_ONLY_PATHS = ["/oidc"] as const
|
||||
|
||||
export const PAGE_PERMISSIONS: Record<string, ConsoleScope[]> = {
|
||||
"/browser": [CONSOLE_SCOPES.VIEW_BROWSER],
|
||||
"/buckets": [CONSOLE_SCOPES.VIEW_BROWSER],
|
||||
@@ -33,5 +36,6 @@ export const PAGE_PERMISSIONS: Record<string, ConsoleScope[]> = {
|
||||
"/tiers": [CONSOLE_SCOPES.VIEW_TIERED_STORAGE],
|
||||
"/events-target": [CONSOLE_SCOPES.VIEW_EVENT_DESTINATIONS],
|
||||
"/sse": [CONSOLE_SCOPES.VIEW_SSE_SETTINGS],
|
||||
"/oidc": [CONSOLE_SCOPES.VIEW_OIDC_SETTINGS],
|
||||
"/license": [CONSOLE_SCOPES.VIEW_LICENSE],
|
||||
}
|
||||
|
||||
+1
-8
@@ -23,13 +23,6 @@
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.d.ts",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.d.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
export type OidcConfigSource = "env" | "persisted"
|
||||
|
||||
export interface OidcConfigProvider {
|
||||
provider_id: string
|
||||
source: OidcConfigSource
|
||||
editable: boolean
|
||||
enabled: boolean
|
||||
display_name: string
|
||||
config_url: string
|
||||
client_id: string
|
||||
client_secret_configured: boolean
|
||||
scopes: string[]
|
||||
redirect_uri: string
|
||||
redirect_uri_dynamic: boolean
|
||||
claim_name: string
|
||||
claim_prefix: string
|
||||
role_policy: string
|
||||
groups_claim: string
|
||||
email_claim: string
|
||||
username_claim: string
|
||||
}
|
||||
|
||||
export interface OidcConfigResponse {
|
||||
providers: OidcConfigProvider[]
|
||||
restart_required: boolean
|
||||
}
|
||||
|
||||
export interface SaveOidcConfigPayload {
|
||||
enabled: boolean
|
||||
display_name: string
|
||||
config_url: string
|
||||
client_id: string
|
||||
client_secret?: string
|
||||
scopes: string[]
|
||||
redirect_uri: string
|
||||
redirect_uri_dynamic: boolean
|
||||
claim_name: string
|
||||
claim_prefix: string
|
||||
role_policy: string
|
||||
groups_claim: string
|
||||
email_claim: string
|
||||
username_claim: string
|
||||
}
|
||||
|
||||
export interface DeleteOidcConfigResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
restart_required: boolean
|
||||
}
|
||||
|
||||
export interface SaveOidcConfigResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
restart_required: boolean
|
||||
}
|
||||
|
||||
export interface ValidateOidcConfigPayload {
|
||||
provider_id: string
|
||||
config_url: string
|
||||
client_id: string
|
||||
client_secret: string
|
||||
scopes: string[]
|
||||
redirect_uri: string
|
||||
redirect_uri_dynamic: boolean
|
||||
}
|
||||
|
||||
export interface ValidateOidcConfigResponse {
|
||||
valid: boolean
|
||||
message: string
|
||||
issuer?: string
|
||||
authorization_endpoint?: string
|
||||
token_endpoint?: string
|
||||
}
|
||||
|
||||
export interface OidcProviderFormValues {
|
||||
provider_id: string
|
||||
enabled: boolean
|
||||
display_name: string
|
||||
config_url: string
|
||||
client_id: string
|
||||
client_secret: string
|
||||
scopes: string
|
||||
redirect_uri: string
|
||||
redirect_uri_dynamic: boolean
|
||||
claim_name: string
|
||||
claim_prefix: string
|
||||
role_policy: string
|
||||
groups_claim: string
|
||||
email_claim: string
|
||||
username_claim: string
|
||||
}
|
||||
|
||||
export type OidcProviderFormErrors = Partial<Record<keyof OidcProviderFormValues, string>>
|
||||
|
||||
export const DEFAULT_OIDC_FORM_VALUES: OidcProviderFormValues = {
|
||||
provider_id: "",
|
||||
enabled: true,
|
||||
display_name: "",
|
||||
config_url: "",
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
scopes: "openid,profile,email",
|
||||
redirect_uri: "",
|
||||
redirect_uri_dynamic: false,
|
||||
claim_name: "groups",
|
||||
claim_prefix: "",
|
||||
role_policy: "",
|
||||
groups_claim: "groups",
|
||||
email_claim: "email",
|
||||
username_claim: "preferred_username",
|
||||
}
|
||||
Reference in New Issue
Block a user