feat: support OIDC login.
This commit is contained in:
@@ -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