feat: support OIDC login.
This commit is contained in:
@@ -62,6 +62,30 @@ export interface SystemSettings {
|
||||
linuxdo_connect_client_secret_configured: boolean
|
||||
linuxdo_connect_redirect_url: string
|
||||
|
||||
// Generic OIDC OAuth settings
|
||||
oidc_connect_enabled: boolean
|
||||
oidc_connect_provider_name: string
|
||||
oidc_connect_client_id: string
|
||||
oidc_connect_client_secret_configured: boolean
|
||||
oidc_connect_issuer_url: string
|
||||
oidc_connect_discovery_url: string
|
||||
oidc_connect_authorize_url: string
|
||||
oidc_connect_token_url: string
|
||||
oidc_connect_userinfo_url: string
|
||||
oidc_connect_jwks_url: string
|
||||
oidc_connect_scopes: string
|
||||
oidc_connect_redirect_url: string
|
||||
oidc_connect_frontend_redirect_url: string
|
||||
oidc_connect_token_auth_method: string
|
||||
oidc_connect_use_pkce: boolean
|
||||
oidc_connect_validate_id_token: boolean
|
||||
oidc_connect_allowed_signing_algs: string
|
||||
oidc_connect_clock_skew_seconds: number
|
||||
oidc_connect_require_email_verified: boolean
|
||||
oidc_connect_userinfo_email_path: string
|
||||
oidc_connect_userinfo_id_path: string
|
||||
oidc_connect_userinfo_username_path: string
|
||||
|
||||
// Model fallback configuration
|
||||
enable_model_fallback: boolean
|
||||
fallback_model_anthropic: string
|
||||
@@ -131,6 +155,28 @@ export interface UpdateSettingsRequest {
|
||||
linuxdo_connect_client_id?: string
|
||||
linuxdo_connect_client_secret?: string
|
||||
linuxdo_connect_redirect_url?: string
|
||||
oidc_connect_enabled?: boolean
|
||||
oidc_connect_provider_name?: string
|
||||
oidc_connect_client_id?: string
|
||||
oidc_connect_client_secret?: string
|
||||
oidc_connect_issuer_url?: string
|
||||
oidc_connect_discovery_url?: string
|
||||
oidc_connect_authorize_url?: string
|
||||
oidc_connect_token_url?: string
|
||||
oidc_connect_userinfo_url?: string
|
||||
oidc_connect_jwks_url?: string
|
||||
oidc_connect_scopes?: string
|
||||
oidc_connect_redirect_url?: string
|
||||
oidc_connect_frontend_redirect_url?: string
|
||||
oidc_connect_token_auth_method?: string
|
||||
oidc_connect_use_pkce?: boolean
|
||||
oidc_connect_validate_id_token?: boolean
|
||||
oidc_connect_allowed_signing_algs?: string
|
||||
oidc_connect_clock_skew_seconds?: number
|
||||
oidc_connect_require_email_verified?: boolean
|
||||
oidc_connect_userinfo_email_path?: string
|
||||
oidc_connect_userinfo_id_path?: string
|
||||
oidc_connect_userinfo_username_path?: string
|
||||
enable_model_fallback?: boolean
|
||||
fallback_model_anthropic?: string
|
||||
fallback_model_openai?: string
|
||||
|
||||
@@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OIDC OAuth registration by supplying an invitation code
|
||||
* @param pendingOAuthToken - Short-lived JWT from the OAuth callback
|
||||
* @param invitationCode - Invitation code entered by the user
|
||||
* @returns Token pair on success
|
||||
*/
|
||||
export async function completeOIDCOAuthRegistration(
|
||||
pendingOAuthToken: string,
|
||||
invitationCode: string
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}>('/auth/oauth/oidc/complete-registration', {
|
||||
pending_oauth_token: pendingOAuthToken,
|
||||
invitation_code: invitationCode
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
login,
|
||||
login2FA,
|
||||
@@ -380,7 +402,8 @@ export const authAPI = {
|
||||
resetPassword,
|
||||
refreshToken,
|
||||
revokeAllSessions,
|
||||
completeLinuxDoOAuthRegistration
|
||||
completeLinuxDoOAuthRegistration,
|
||||
completeOIDCOAuthRegistration
|
||||
}
|
||||
|
||||
export default authAPI
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
{{ t('auth.linuxdo.signIn') }}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="showDivider" class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.linuxdo.orContinue') }}
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
@@ -43,9 +43,12 @@
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
@@ -58,4 +61,3 @@ function startLogin(): void {
|
||||
window.location.href = startURL
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
53
frontend/src/components/auth/OidcOAuthSection.vue
Normal file
53
frontend/src/components/auth/OidcOAuthSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
|
||||
<span
|
||||
class="mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ providerInitial }}
|
||||
</span>
|
||||
{{ t('auth.oidc.signIn', { providerName: normalizedProviderName }) }}
|
||||
</button>
|
||||
|
||||
<div v-if="showDivider" class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
providerName?: string
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
providerName: 'OIDC',
|
||||
showDivider: true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const normalizedProviderName = computed(() => {
|
||||
const name = props.providerName?.trim()
|
||||
return name || 'OIDC'
|
||||
})
|
||||
|
||||
const providerInitial = computed(() => normalizedProviderName.value.charAt(0).toUpperCase() || 'O')
|
||||
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
}
|
||||
</script>
|
||||
@@ -428,6 +428,7 @@ export default {
|
||||
invitationCodeInvalid: 'Invalid or used invitation code',
|
||||
invitationCodeValidating: 'Validating invitation code...',
|
||||
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
|
||||
oauthOrContinue: 'or continue with email',
|
||||
linuxdo: {
|
||||
signIn: 'Continue with Linux.do',
|
||||
orContinue: 'or continue with email',
|
||||
@@ -442,6 +443,20 @@ export default {
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||
},
|
||||
oidc: {
|
||||
signIn: 'Continue with {providerName}',
|
||||
callbackTitle: 'Signing you in with {providerName}',
|
||||
callbackProcessing: 'Completing login with {providerName}, please wait...',
|
||||
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
|
||||
callbackMissingToken: 'Missing login token, please try again.',
|
||||
backToLogin: 'Back to Login',
|
||||
invitationRequired:
|
||||
'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
|
||||
invalidPendingToken: 'The registration token has expired. Please sign in again.',
|
||||
completeRegistration: 'Complete Registration',
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||
},
|
||||
oauth: {
|
||||
code: 'Code',
|
||||
state: 'State',
|
||||
@@ -4227,6 +4242,57 @@ export default {
|
||||
quickSetCopy: 'Generate & Copy (current site)',
|
||||
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard'
|
||||
},
|
||||
oidc: {
|
||||
title: 'OIDC Login',
|
||||
description: 'Configure a standard OIDC provider (for example Keycloak)',
|
||||
enable: 'Enable OIDC Login',
|
||||
enableHint: 'Show OIDC login on the login/register pages',
|
||||
providerName: 'Provider Name',
|
||||
providerNamePlaceholder: 'for example Keycloak',
|
||||
clientId: 'Client ID',
|
||||
clientIdPlaceholder: 'OIDC client id',
|
||||
clientSecret: 'Client Secret',
|
||||
clientSecretPlaceholder: '********',
|
||||
clientSecretHint: 'Used by backend to exchange tokens (keep it secret)',
|
||||
clientSecretConfiguredPlaceholder: '********',
|
||||
clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
|
||||
issuerUrl: 'Issuer URL',
|
||||
issuerUrlPlaceholder: 'https://id.example.com/realms/main',
|
||||
discoveryUrl: 'Discovery URL',
|
||||
discoveryUrlPlaceholder: 'Optional, leave empty to auto-derive from issuer',
|
||||
authorizeUrl: 'Authorize URL',
|
||||
authorizeUrlPlaceholder: 'Optional, can be discovered automatically',
|
||||
tokenUrl: 'Token URL',
|
||||
tokenUrlPlaceholder: 'Optional, can be discovered automatically',
|
||||
userinfoUrl: 'UserInfo URL',
|
||||
userinfoUrlPlaceholder: 'Optional, can be discovered automatically',
|
||||
jwksUrl: 'JWKS URL',
|
||||
jwksUrlPlaceholder: 'Optional, required when strict ID token validation is enabled',
|
||||
scopes: 'Scopes',
|
||||
scopesPlaceholder: 'openid email profile',
|
||||
scopesHint: 'Must include openid',
|
||||
redirectUrl: 'Backend Redirect URL',
|
||||
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback',
|
||||
redirectUrlHint: 'Must match the callback URL configured in the OIDC provider',
|
||||
quickSetCopy: 'Generate & Copy (current site)',
|
||||
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard',
|
||||
frontendRedirectUrl: 'Frontend Callback Path',
|
||||
frontendRedirectUrlPlaceholder: '/auth/oidc/callback',
|
||||
frontendRedirectUrlHint: 'Frontend route used after backend callback',
|
||||
tokenAuthMethod: 'Token Auth Method',
|
||||
clockSkewSeconds: 'Clock Skew (seconds)',
|
||||
allowedSigningAlgs: 'Allowed Signing Algs',
|
||||
allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256',
|
||||
usePkce: 'Use PKCE',
|
||||
validateIdToken: 'Validate ID Token',
|
||||
requireEmailVerified: 'Require Email Verified',
|
||||
userinfoEmailPath: 'UserInfo Email Path',
|
||||
userinfoEmailPathPlaceholder: 'for example data.email',
|
||||
userinfoIdPath: 'UserInfo ID Path',
|
||||
userinfoIdPathPlaceholder: 'for example data.id',
|
||||
userinfoUsernamePath: 'UserInfo Username Path',
|
||||
userinfoUsernamePathPlaceholder: 'for example data.username'
|
||||
},
|
||||
defaults: {
|
||||
title: 'Default User Settings',
|
||||
description: 'Default values for new users',
|
||||
|
||||
@@ -427,6 +427,7 @@ export default {
|
||||
invitationCodeInvalid: '邀请码无效或已被使用',
|
||||
invitationCodeValidating: '正在验证邀请码...',
|
||||
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
|
||||
oauthOrContinue: '或使用邮箱密码继续',
|
||||
linuxdo: {
|
||||
signIn: '使用 Linux.do 登录',
|
||||
orContinue: '或使用邮箱密码继续',
|
||||
@@ -441,6 +442,19 @@ export default {
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||
},
|
||||
oidc: {
|
||||
signIn: '使用 {providerName} 登录',
|
||||
callbackTitle: '正在完成 {providerName} 登录',
|
||||
callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...',
|
||||
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
||||
callbackMissingToken: '登录信息缺失,请返回重试。',
|
||||
backToLogin: '返回登录',
|
||||
invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
|
||||
invalidPendingToken: '注册凭证已失效,请重新登录。',
|
||||
completeRegistration: '完成注册',
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||
},
|
||||
oauth: {
|
||||
code: '授权码',
|
||||
state: '状态',
|
||||
@@ -4393,6 +4407,57 @@ export default {
|
||||
quickSetCopy: '使用当前站点生成并复制',
|
||||
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板'
|
||||
},
|
||||
oidc: {
|
||||
title: 'OIDC 登录',
|
||||
description: '配置标准 OIDC Provider(例如 Keycloak)',
|
||||
enable: '启用 OIDC 登录',
|
||||
enableHint: '在登录/注册页面显示 OIDC 登录入口',
|
||||
providerName: 'Provider 名称',
|
||||
providerNamePlaceholder: '例如 Keycloak',
|
||||
clientId: 'Client ID',
|
||||
clientIdPlaceholder: 'OIDC client id',
|
||||
clientSecret: 'Client Secret',
|
||||
clientSecretPlaceholder: '********',
|
||||
clientSecretHint: '用于后端交换 token(请保密)',
|
||||
clientSecretConfiguredPlaceholder: '********',
|
||||
clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。',
|
||||
issuerUrl: 'Issuer URL',
|
||||
issuerUrlPlaceholder: 'https://id.example.com/realms/main',
|
||||
discoveryUrl: 'Discovery URL',
|
||||
discoveryUrlPlaceholder: '可选,留空将基于 issuer 自动推导',
|
||||
authorizeUrl: 'Authorize URL',
|
||||
authorizeUrlPlaceholder: '可选,可通过 discovery 自动获取',
|
||||
tokenUrl: 'Token URL',
|
||||
tokenUrlPlaceholder: '可选,可通过 discovery 自动获取',
|
||||
userinfoUrl: 'UserInfo URL',
|
||||
userinfoUrlPlaceholder: '可选,可通过 discovery 自动获取',
|
||||
jwksUrl: 'JWKS URL',
|
||||
jwksUrlPlaceholder: '可选;启用严格 ID Token 校验时必填',
|
||||
scopes: 'Scopes',
|
||||
scopesPlaceholder: 'openid email profile',
|
||||
scopesHint: '必须包含 openid',
|
||||
redirectUrl: '后端回调地址(Redirect URL)',
|
||||
redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback',
|
||||
redirectUrlHint: '必须与 OIDC Provider 中配置的回调地址一致',
|
||||
quickSetCopy: '使用当前站点生成并复制',
|
||||
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板',
|
||||
frontendRedirectUrl: '前端回调路径',
|
||||
frontendRedirectUrlPlaceholder: '/auth/oidc/callback',
|
||||
frontendRedirectUrlHint: '后端回调完成后重定向到此前端路径',
|
||||
tokenAuthMethod: 'Token 鉴权方式',
|
||||
clockSkewSeconds: '时钟偏移(秒)',
|
||||
allowedSigningAlgs: '允许的签名算法',
|
||||
allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256',
|
||||
usePkce: '启用 PKCE',
|
||||
validateIdToken: '校验 ID Token',
|
||||
requireEmailVerified: '要求邮箱已验证',
|
||||
userinfoEmailPath: 'UserInfo 邮箱字段路径',
|
||||
userinfoEmailPathPlaceholder: '例如 data.email',
|
||||
userinfoIdPath: 'UserInfo ID 字段路径',
|
||||
userinfoIdPathPlaceholder: '例如 data.id',
|
||||
userinfoUsernamePath: 'UserInfo 用户名字段路径',
|
||||
userinfoUsernamePathPlaceholder: '例如 data.username'
|
||||
},
|
||||
defaults: {
|
||||
title: '用户默认设置',
|
||||
description: '新用户的默认值',
|
||||
|
||||
@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
|
||||
title: 'LinuxDo OAuth Callback'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/auth/oidc/callback',
|
||||
name: 'OIDCOAuthCallback',
|
||||
component: () => import('@/views/auth/OidcCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'OIDC OAuth Callback'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
|
||||
@@ -332,6 +332,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
custom_menu_items: [],
|
||||
custom_endpoints: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
oidc_oauth_enabled: false,
|
||||
oidc_oauth_provider_name: 'OIDC',
|
||||
sora_client_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
version: siteVersion.value
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ export interface PublicSettings {
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
custom_endpoints: CustomEndpoint[]
|
||||
linuxdo_oauth_enabled: boolean
|
||||
oidc_oauth_enabled: boolean
|
||||
oidc_oauth_provider_name: string
|
||||
sora_client_enabled: boolean
|
||||
backend_mode_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
|
||||
@@ -1124,7 +1124,327 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Tab: Security — Registration, Turnstile, LinuxDo -->
|
||||
|
||||
<!-- Generic OIDC OAuth 登录 -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.oidc.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.oidc.enable')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.enableHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.oidc_connect_enabled" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.oidc_connect_enabled"
|
||||
class="space-y-6 border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.providerName') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_provider_name"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.settings.oidc.providerNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.clientId') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_client_id"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.clientIdPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.clientSecret') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_client_secret"
|
||||
type="password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="
|
||||
form.oidc_connect_client_secret_configured
|
||||
? t('admin.settings.oidc.clientSecretConfiguredPlaceholder')
|
||||
: t('admin.settings.oidc.clientSecretPlaceholder')
|
||||
"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
form.oidc_connect_client_secret_configured
|
||||
? t('admin.settings.oidc.clientSecretConfiguredHint')
|
||||
: t('admin.settings.oidc.clientSecretHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.issuerUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_issuer_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.issuerUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.discoveryUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_discovery_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.discoveryUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.authorizeUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_authorize_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.authorizeUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.tokenUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_token_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.tokenUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.userinfoUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_userinfo_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.userinfoUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.jwksUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_jwks_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.jwksUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.scopes') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_scopes"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.scopesPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.scopesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.redirectUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_redirect_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.redirectUrlPlaceholder')"
|
||||
/>
|
||||
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm w-fit"
|
||||
@click="setAndCopyOIDCRedirectUrl"
|
||||
>
|
||||
{{ t('admin.settings.oidc.quickSetCopy') }}
|
||||
</button>
|
||||
<code
|
||||
v-if="oidcRedirectUrlSuggestion"
|
||||
class="select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
|
||||
>
|
||||
{{ oidcRedirectUrlSuggestion }}
|
||||
</code>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.redirectUrlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.frontendRedirectUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_frontend_redirect_url"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.frontendRedirectUrlPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.oidc.frontendRedirectUrlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.tokenAuthMethod') }}
|
||||
</label>
|
||||
<select v-model="form.oidc_connect_token_auth_method" class="input font-mono text-sm">
|
||||
<option value="client_secret_post">client_secret_post</option>
|
||||
<option value="client_secret_basic">client_secret_basic</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.clockSkewSeconds') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.oidc_connect_clock_skew_seconds"
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.allowedSigningAlgs') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_allowed_signing_algs"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.allowedSigningAlgsPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.oidc.usePkce') }}
|
||||
</label>
|
||||
</div>
|
||||
<Toggle v-model="form.oidc_connect_use_pkce" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.oidc.validateIdToken') }}
|
||||
</label>
|
||||
</div>
|
||||
<Toggle v-model="form.oidc_connect_validate_id_token" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.oidc.requireEmailVerified') }}
|
||||
</label>
|
||||
</div>
|
||||
<Toggle v-model="form.oidc_connect_require_email_verified" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.userinfoEmailPath') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_userinfo_email_path"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.userinfoEmailPathPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.userinfoIdPath') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_userinfo_id_path"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.userinfoIdPathPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.oidc.userinfoUsernamePath') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.oidc_connect_userinfo_username_path"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.oidc.userinfoUsernamePathPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Tab: Security — Registration, Turnstile, LinuxDo, OIDC -->
|
||||
|
||||
<!-- Tab: Users -->
|
||||
<div v-show="activeTab === 'users'" class="space-y-6">
|
||||
@@ -2193,6 +2513,7 @@ type SettingsForm = SystemSettings & {
|
||||
smtp_password: string
|
||||
turnstile_secret_key: string
|
||||
linuxdo_connect_client_secret: string
|
||||
oidc_connect_client_secret: string
|
||||
}
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
@@ -2240,6 +2561,30 @@ const form = reactive<SettingsForm>({
|
||||
linuxdo_connect_client_secret: '',
|
||||
linuxdo_connect_client_secret_configured: false,
|
||||
linuxdo_connect_redirect_url: '',
|
||||
// Generic OIDC OAuth 登录
|
||||
oidc_connect_enabled: false,
|
||||
oidc_connect_provider_name: 'OIDC',
|
||||
oidc_connect_client_id: '',
|
||||
oidc_connect_client_secret: '',
|
||||
oidc_connect_client_secret_configured: false,
|
||||
oidc_connect_issuer_url: '',
|
||||
oidc_connect_discovery_url: '',
|
||||
oidc_connect_authorize_url: '',
|
||||
oidc_connect_token_url: '',
|
||||
oidc_connect_userinfo_url: '',
|
||||
oidc_connect_jwks_url: '',
|
||||
oidc_connect_scopes: 'openid email profile',
|
||||
oidc_connect_redirect_url: '',
|
||||
oidc_connect_frontend_redirect_url: '/auth/oidc/callback',
|
||||
oidc_connect_token_auth_method: 'client_secret_post',
|
||||
oidc_connect_use_pkce: false,
|
||||
oidc_connect_validate_id_token: true,
|
||||
oidc_connect_allowed_signing_algs: 'RS256,ES256,PS256',
|
||||
oidc_connect_clock_skew_seconds: 120,
|
||||
oidc_connect_require_email_verified: false,
|
||||
oidc_connect_userinfo_email_path: '',
|
||||
oidc_connect_userinfo_id_path: '',
|
||||
oidc_connect_userinfo_username_path: '',
|
||||
// Model fallback
|
||||
enable_model_fallback: false,
|
||||
fallback_model_anthropic: 'claude-3-5-sonnet-20241022',
|
||||
@@ -2360,6 +2705,21 @@ async function setAndCopyLinuxdoRedirectUrl() {
|
||||
await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied'))
|
||||
}
|
||||
|
||||
const oidcRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const origin =
|
||||
window.location.origin || `${window.location.protocol}//${window.location.host}`
|
||||
return `${origin}/api/v1/auth/oauth/oidc/callback`
|
||||
})
|
||||
|
||||
async function setAndCopyOIDCRedirectUrl() {
|
||||
const url = oidcRedirectUrlSuggestion.value
|
||||
if (!url) return
|
||||
|
||||
form.oidc_connect_redirect_url = url
|
||||
await copyToClipboard(url, t('admin.settings.oidc.redirectUrlSetAndCopied'))
|
||||
}
|
||||
|
||||
// Custom menu item management
|
||||
function addMenuItem() {
|
||||
form.custom_menu_items.push({
|
||||
@@ -2425,6 +2785,7 @@ async function loadSettings() {
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
form.turnstile_secret_key = ''
|
||||
form.linuxdo_connect_client_secret = ''
|
||||
form.oidc_connect_client_secret = ''
|
||||
} catch (error: any) {
|
||||
loadFailed.value = true
|
||||
appStore.showError(
|
||||
@@ -2559,6 +2920,28 @@ async function saveSettings() {
|
||||
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
|
||||
linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined,
|
||||
linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url,
|
||||
oidc_connect_enabled: form.oidc_connect_enabled,
|
||||
oidc_connect_provider_name: form.oidc_connect_provider_name,
|
||||
oidc_connect_client_id: form.oidc_connect_client_id,
|
||||
oidc_connect_client_secret: form.oidc_connect_client_secret || undefined,
|
||||
oidc_connect_issuer_url: form.oidc_connect_issuer_url,
|
||||
oidc_connect_discovery_url: form.oidc_connect_discovery_url,
|
||||
oidc_connect_authorize_url: form.oidc_connect_authorize_url,
|
||||
oidc_connect_token_url: form.oidc_connect_token_url,
|
||||
oidc_connect_userinfo_url: form.oidc_connect_userinfo_url,
|
||||
oidc_connect_jwks_url: form.oidc_connect_jwks_url,
|
||||
oidc_connect_scopes: form.oidc_connect_scopes,
|
||||
oidc_connect_redirect_url: form.oidc_connect_redirect_url,
|
||||
oidc_connect_frontend_redirect_url: form.oidc_connect_frontend_redirect_url,
|
||||
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
|
||||
oidc_connect_use_pkce: form.oidc_connect_use_pkce,
|
||||
oidc_connect_validate_id_token: form.oidc_connect_validate_id_token,
|
||||
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
|
||||
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
|
||||
oidc_connect_require_email_verified: form.oidc_connect_require_email_verified,
|
||||
oidc_connect_userinfo_email_path: form.oidc_connect_userinfo_email_path,
|
||||
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
|
||||
oidc_connect_userinfo_username_path: form.oidc_connect_userinfo_username_path,
|
||||
enable_model_fallback: form.enable_model_fallback,
|
||||
fallback_model_anthropic: form.fallback_model_anthropic,
|
||||
fallback_model_openai: form.fallback_model_openai,
|
||||
@@ -2583,6 +2966,7 @@ async function saveSettings() {
|
||||
smtpPasswordManuallyEdited.value = false
|
||||
form.turnstile_secret_key = ''
|
||||
form.linuxdo_connect_client_secret = ''
|
||||
form.oidc_connect_client_secret = ''
|
||||
// Refresh cached settings so sidebar/header update immediately
|
||||
await appStore.fetchPublicSettings(true)
|
||||
await adminSettingsStore.fetch(true)
|
||||
|
||||
@@ -11,8 +11,26 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- LinuxDo Connect OAuth 登录 -->
|
||||
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled && !backendModeEnabled" :disabled="isLoading" />
|
||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
@@ -207,6 +226,8 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const backendModeEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
|
||||
// Turnstile
|
||||
@@ -247,6 +268,9 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
passwordResetEnabled.value = settings.password_reset_enabled
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
|
||||
234
frontend/src/views/auth/OidcCallbackView.vue
Normal file
234
frontend/src/views/auth/OidcCallbackView.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oidc.callbackTitle', { providerName }) }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
isProcessing
|
||||
? t('auth.oidc.callbackProcessing', { providerName })
|
||||
: t('auth.oidc.callbackHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="needsInvitation" class="space-y-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{
|
||||
isSubmitting
|
||||
? t('auth.oidc.completing')
|
||||
: t('auth.oidc.completeRegistration')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<router-link to="/login" class="btn btn-primary">
|
||||
{{ t('auth.oidc.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
completeOIDCOAuthRegistration,
|
||||
getPublicSettings
|
||||
} from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isProcessing = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const needsInvitation = ref(false)
|
||||
const pendingOAuthToken = ref('')
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const providerName = ref('OIDC')
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||
return new URLSearchParams(hash)
|
||||
}
|
||||
|
||||
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
if (!path) return '/dashboard'
|
||||
if (!path.startsWith('/')) return '/dashboard'
|
||||
if (path.startsWith('//')) return '/dashboard'
|
||||
if (path.includes('://')) return '/dashboard'
|
||||
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||
return path
|
||||
}
|
||||
|
||||
async function loadProviderName() {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
const name = settings.oidc_oauth_provider_name?.trim()
|
||||
if (name) {
|
||||
providerName.value = name
|
||||
}
|
||||
} catch {
|
||||
// Ignore; fallback remains OIDC
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeOIDCOAuthRegistration(
|
||||
pendingOAuthToken.value,
|
||||
invitationCode.value.trim()
|
||||
)
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
}
|
||||
if (tokenData.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||
}
|
||||
await authStore.setToken(tokenData.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||
invitationError.value =
|
||||
err.response?.data?.message || err.message || t('auth.oidc.completeRegistrationFailed')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void loadProviderName()
|
||||
|
||||
const params = parseFragmentParams()
|
||||
const token = params.get('access_token') || ''
|
||||
const refreshToken = params.get('refresh_token') || ''
|
||||
const expiresInStr = params.get('expires_in') || ''
|
||||
const redirect = sanitizeRedirectPath(
|
||||
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (error) {
|
||||
if (error === 'invitation_required') {
|
||||
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
||||
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
||||
if (!pendingOAuthToken.value) {
|
||||
errorMessage.value = t('auth.oidc.invalidPendingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
errorMessage.value = t('auth.oidc.callbackMissingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
}
|
||||
if (expiresInStr) {
|
||||
const expiresIn = parseInt(expiresInStr, 10)
|
||||
if (!isNaN(expiresIn)) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + expiresIn * 1000))
|
||||
}
|
||||
}
|
||||
|
||||
await authStore.setToken(token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
||||
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -11,8 +11,26 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- LinuxDo Connect OAuth 登录 -->
|
||||
<LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" />
|
||||
<div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration Disabled Message -->
|
||||
<div
|
||||
@@ -289,6 +307,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
@@ -324,6 +343,8 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
|
||||
// Turnstile
|
||||
@@ -376,6 +397,8 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
settings.registration_email_suffix_whitelist || []
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user