Merge remote-tracking branch 'origin/pr/487'

# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
#	crates/aether-data/src/repository/oauth_providers/postgres.rs
#	crates/aether-data/src/repository/oauth_providers/sqlite.rs
#	frontend/src/views/admin/OAuthSettings.vue
This commit is contained in:
fawney19
2026-05-19 02:27:39 +08:00
27 changed files with 293 additions and 204 deletions

View File

@@ -3,6 +3,7 @@ import apiClient from './client'
export interface OAuthProviderInfo {
provider_type: string
display_name: string
icon_url?: string | null
}
export interface OAuthProvidersResponse {
@@ -46,6 +47,7 @@ export interface OAuthProviderAdminConfig {
frontend_callback_url: string
attribute_mapping?: Record<string, unknown> | null
extra_config?: Record<string, unknown> | null
icon_url?: string | null
is_enabled: boolean
}
@@ -61,6 +63,7 @@ export interface OAuthProviderUpsertRequest {
frontend_callback_url: string
attribute_mapping?: Record<string, unknown> | null
extra_config?: Record<string, unknown> | null
icon_url?: string | null
is_enabled: boolean
force?: boolean
}

View File

@@ -63,7 +63,7 @@
<!-- eslint-disable vue/no-v-html -->
<span
class="oauth-icon"
v-html="getOAuthIcon(oauthProviders[0].provider_type)"
v-html="getOAuthIcon(oauthProviders[0].provider_type, oauthProviders[0].icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
<span>使用 {{ oauthProviders[0].display_name }} 登录</span>
@@ -88,7 +88,7 @@
<!-- eslint-disable vue/no-v-html -->
<span
class="oauth-icon-lg"
v-html="getOAuthIcon(p.provider_type)"
v-html="getOAuthIcon(p.provider_type, p.icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
</button>

View File

@@ -10,6 +10,9 @@ export const OAUTH_ICONS: Record<string, string> = {
// Default icon when provider type is not found
const DEFAULT_ICON = OAUTH_ICONS.github
export function getOAuthIcon(providerType: string): string {
return OAUTH_ICONS[providerType.toLowerCase()] || DEFAULT_ICON
export function getOAuthIcon(providerType: string, iconUrl?: string | null): string {
const builtin = OAUTH_ICONS[providerType.toLowerCase()]
if (builtin) return builtin
if (iconUrl) return `<img src="${iconUrl}" alt="" style="width:100%;height:100%;object-fit:contain;" />`
return DEFAULT_ICON
}

View File

@@ -131,197 +131,209 @@
</div>
</template>
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.new_display_name"
class="mt-1"
placeholder="例如My OIDC Provider"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
<div class="space-y-6">
<!-- 新建时的 Display Name -->
<div
v-if="selectedType === '__new__'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div>
<Label class="block text-sm font-medium">显示名称</Label>
<Input
v-model="form.new_display_name"
class="mt-1"
placeholder="例如My OIDC Provider"
autocomplete="off"
/>
</div>
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
<div>
<Label class="block text-sm font-medium">配置标识</Label>
<Input
v-model="form.new_provider_type"
class="mt-1"
placeholder="custom_oidc_work"
autocomplete="off"
@blur="normalizeNewProviderType"
/>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI后端回调</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 高级选项折叠 -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Input
v-model="form.scopes_input"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔留空使用默认值
</p>
</div>
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填填写 Authorization / Token / Userinfo URL 所属域名
</p>
</div>
</div>
</div>
</details>
</div>
<!-- 测试结果 -->
<!-- 凭证配置 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Client ID</Label>
<Input
v-model="form.client_id"
class="mt-1"
placeholder="client_id"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Client Secret</Label>
<Input
v-model="form.client_secret"
masked
class="mt-1"
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
/>
</div>
</div>
<!-- 回调地址 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Redirect URI后端回调</Label>
<Input
v-model="form.redirect_uri"
class="mt-1"
placeholder="http://localhost:8084/api/oauth/xxx/callback"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">前端回调页</Label>
<Input
v-model="form.frontend_callback_url"
class="mt-1"
placeholder="http://localhost:5173/auth/callback"
autocomplete="off"
/>
</div>
</div>
<!-- custom_oidc 必填端点 -->
<div
v-if="isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
placeholder="https://example.com/oauth/authorize"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
placeholder="https://example.com/oauth/token"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
placeholder="https://example.com/api/user"
autocomplete="off"
/>
</div>
</div>
<!-- 图标 URL -->
<div>
<Label class="block text-sm font-medium">图标 URL</Label>
<Input
v-model="form.icon_url"
class="mt-1"
placeholder="https://example.com/icon.svg"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
登录页显示的 Provider 图标留空使用默认图标
</p>
</div>
<!-- 高级选项折叠 -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
高级选项
</summary>
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
<div>
<Label class="block text-sm font-medium">Scopes</Label>
<Input
v-model="form.scopes_input"
class="mt-1"
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
autocomplete="off"
/>
<p class="mt-1 text-xs text-muted-foreground">
空格/逗号分隔留空使用默认值
</p>
</div>
<!-- linuxdo 的可选端点覆盖 -->
<div
v-if="!isSelectedCustomProvider"
class="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<div>
<Label class="block text-sm font-medium">Authorization URL</Label>
<Input
v-model="form.authorization_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Token URL</Label>
<Input
v-model="form.token_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
autocomplete="off"
/>
</div>
<div>
<Label class="block text-sm font-medium">Userinfo URL</Label>
<Input
v-model="form.userinfo_url_override"
class="mt-1"
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
autocomplete="off"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label class="block text-sm font-medium">Attribute Mapping</Label>
<Textarea
v-model="form.attribute_mapping_json"
class="mt-1 font-mono text-xs"
rows="3"
placeholder="{&quot;id&quot;: &quot;user_id&quot;, &quot;username&quot;: &quot;login&quot;}"
/>
</div>
<div>
<Label class="block text-sm font-medium">
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
</Label>
<Textarea
v-model="form.extra_config_json"
class="mt-1 font-mono text-xs"
rows="3"
:placeholder="extraConfigPlaceholder"
/>
<p
v-if="isSelectedCustomProvider"
class="mt-1 text-xs text-muted-foreground"
>
自定义 OIDC 必填填写 Authorization / Token / Userinfo URL 所属域名
</p>
</div>
</div>
</div>
</details>
</div>
<div
v-if="lastTestResult"
class="mt-6 rounded-lg border border-border p-4 text-sm"
@@ -402,6 +414,7 @@ interface OAuthConfigForm {
frontend_callback_url: string
attribute_mapping_json: string
extra_config_json: string
icon_url: string
new_provider_type: string
new_display_name: string
}
@@ -423,6 +436,7 @@ const form = ref<OAuthConfigForm>({
frontend_callback_url: '',
attribute_mapping_json: '',
extra_config_json: '',
icon_url: '',
new_provider_type: '',
new_display_name: '',
})
@@ -589,6 +603,7 @@ function handleClickAdd() {
frontend_callback_url: defaultFrontendCallbackUrl(),
attribute_mapping_json: '',
extra_config_json: '',
icon_url: '',
new_provider_type: providerType,
new_display_name: '',
}
@@ -633,6 +648,7 @@ function syncFormFromSelected() {
frontend_callback_url: cfg?.frontend_callback_url || defaultFrontendCallbackUrl(),
attribute_mapping_json: cfg?.attribute_mapping ? JSON.stringify(cfg.attribute_mapping, null, 2) : '',
extra_config_json: cfg?.extra_config ? JSON.stringify(cfg.extra_config, null, 2) : '',
icon_url: cfg?.icon_url || '',
new_provider_type: '',
new_display_name: '',
}
@@ -658,6 +674,7 @@ async function toggleProviderEnabled(providerType: string, enabled: boolean, for
frontend_callback_url: cfg.frontend_callback_url,
attribute_mapping: cfg.attribute_mapping || null,
extra_config: cfg.extra_config || null,
icon_url: cfg.icon_url || null,
is_enabled: enabled,
force,
}
@@ -732,6 +749,7 @@ async function handleSave() {
frontend_callback_url: form.value.frontend_callback_url.trim(),
attribute_mapping: parseJsonOrNull(form.value.attribute_mapping_json),
extra_config: parseJsonOrNull(form.value.extra_config_json),
icon_url: form.value.icon_url.trim() || null,
is_enabled: existingConfig?.is_enabled || false,
}

View File

@@ -387,7 +387,7 @@
<!-- eslint-disable vue/no-v-html -->
<div
class="oauth-icon shrink-0"
v-html="getOAuthIcon(p.provider_type)"
v-html="getOAuthIcon(p.provider_type, p.icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
<div class="min-w-0">