feat: 增加 GitHub 和 Google 邮箱快捷登录
This commit is contained in:
@@ -11,7 +11,13 @@ export interface DefaultSubscriptionSetting {
|
||||
validity_days: number;
|
||||
}
|
||||
|
||||
export type AuthSourceType = "email" | "linuxdo" | "oidc" | "wechat";
|
||||
export type AuthSourceType =
|
||||
| "email"
|
||||
| "linuxdo"
|
||||
| "oidc"
|
||||
| "wechat"
|
||||
| "github"
|
||||
| "google";
|
||||
|
||||
export interface AuthSourceDefaultsValue {
|
||||
balance: number;
|
||||
@@ -51,6 +57,8 @@ const AUTH_SOURCE_TYPES: AuthSourceType[] = [
|
||||
"linuxdo",
|
||||
"oidc",
|
||||
"wechat",
|
||||
"github",
|
||||
"google",
|
||||
];
|
||||
const AUTH_SOURCE_DEFAULT_BALANCE = 0;
|
||||
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5;
|
||||
@@ -335,6 +343,16 @@ export interface SystemSettings {
|
||||
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
|
||||
auth_source_default_wechat_grant_on_signup?: boolean;
|
||||
auth_source_default_wechat_grant_on_first_bind?: boolean;
|
||||
auth_source_default_github_balance?: number;
|
||||
auth_source_default_github_concurrency?: number;
|
||||
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
|
||||
auth_source_default_github_grant_on_signup?: boolean;
|
||||
auth_source_default_github_grant_on_first_bind?: boolean;
|
||||
auth_source_default_google_balance?: number;
|
||||
auth_source_default_google_concurrency?: number;
|
||||
auth_source_default_google_subscriptions?: DefaultSubscriptionSetting[];
|
||||
auth_source_default_google_grant_on_signup?: boolean;
|
||||
auth_source_default_google_grant_on_first_bind?: boolean;
|
||||
force_email_on_third_party_signup?: boolean;
|
||||
// OEM settings
|
||||
site_name: string;
|
||||
@@ -410,6 +428,16 @@ export interface SystemSettings {
|
||||
oidc_connect_userinfo_email_path: string;
|
||||
oidc_connect_userinfo_id_path: string;
|
||||
oidc_connect_userinfo_username_path: string;
|
||||
github_oauth_enabled: boolean;
|
||||
github_oauth_client_id: string;
|
||||
github_oauth_client_secret_configured: boolean;
|
||||
github_oauth_redirect_url: string;
|
||||
github_oauth_frontend_redirect_url: string;
|
||||
google_oauth_enabled: boolean;
|
||||
google_oauth_client_id: string;
|
||||
google_oauth_client_secret_configured: boolean;
|
||||
google_oauth_redirect_url: string;
|
||||
google_oauth_frontend_redirect_url: string;
|
||||
|
||||
// Model fallback configuration
|
||||
enable_model_fallback: boolean;
|
||||
@@ -527,6 +555,16 @@ export interface UpdateSettingsRequest {
|
||||
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
|
||||
auth_source_default_wechat_grant_on_signup?: boolean;
|
||||
auth_source_default_wechat_grant_on_first_bind?: boolean;
|
||||
auth_source_default_github_balance?: number;
|
||||
auth_source_default_github_concurrency?: number;
|
||||
auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[];
|
||||
auth_source_default_github_grant_on_signup?: boolean;
|
||||
auth_source_default_github_grant_on_first_bind?: boolean;
|
||||
auth_source_default_google_balance?: number;
|
||||
auth_source_default_google_concurrency?: number;
|
||||
auth_source_default_google_subscriptions?: DefaultSubscriptionSetting[];
|
||||
auth_source_default_google_grant_on_signup?: boolean;
|
||||
auth_source_default_google_grant_on_first_bind?: boolean;
|
||||
force_email_on_third_party_signup?: boolean;
|
||||
site_name?: string;
|
||||
site_logo?: string;
|
||||
@@ -593,6 +631,16 @@ export interface UpdateSettingsRequest {
|
||||
oidc_connect_userinfo_email_path?: string;
|
||||
oidc_connect_userinfo_id_path?: string;
|
||||
oidc_connect_userinfo_username_path?: string;
|
||||
github_oauth_enabled?: boolean;
|
||||
github_oauth_client_id?: string;
|
||||
github_oauth_client_secret?: string;
|
||||
github_oauth_redirect_url?: string;
|
||||
github_oauth_frontend_redirect_url?: string;
|
||||
google_oauth_enabled?: boolean;
|
||||
google_oauth_client_id?: string;
|
||||
google_oauth_client_secret?: string;
|
||||
google_oauth_redirect_url?: string;
|
||||
google_oauth_frontend_redirect_url?: string;
|
||||
enable_model_fallback?: boolean;
|
||||
fallback_model_anthropic?: string;
|
||||
fallback_model_openai?: string;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div v-if="hasProviders" class="space-y-4">
|
||||
<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 :class="providerGridClass">
|
||||
<button
|
||||
v-for="provider in visibleProviders"
|
||||
:key="provider"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="btn btn-secondary h-12 w-full justify-center gap-2"
|
||||
@click="startLogin(provider)"
|
||||
>
|
||||
<GitHubMark v-if="provider === 'github'" class="h-5 w-5 text-gray-800 dark:text-gray-100" />
|
||||
<GoogleMark v-else class="h-5 w-5" />
|
||||
<span class="font-medium">{{ providerLabel(provider) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GitHubMark from './GitHubMark.vue'
|
||||
import GoogleMark from './GoogleMark.vue'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
type EmailOAuthProvider = 'github' | 'google'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
affCode?: string
|
||||
githubEnabled?: boolean
|
||||
googleEnabled?: boolean
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const visibleProviders = computed<EmailOAuthProvider[]>(() => {
|
||||
const providers: EmailOAuthProvider[] = []
|
||||
if (props.githubEnabled) providers.push('github')
|
||||
if (props.googleEnabled) providers.push('google')
|
||||
return providers
|
||||
})
|
||||
|
||||
const hasProviders = computed(() => visibleProviders.value.length > 0)
|
||||
const hasMultipleProviders = computed(() => visibleProviders.value.length > 1)
|
||||
const providerGridClass = computed(() => [
|
||||
'grid',
|
||||
'grid-cols-1',
|
||||
'gap-3',
|
||||
hasMultipleProviders.value ? 'sm:grid-cols-2' : ''
|
||||
])
|
||||
|
||||
function providerLabel(provider: EmailOAuthProvider): string {
|
||||
const name = provider === 'github' ? 'GitHub' : 'Google'
|
||||
return hasMultipleProviders.value ? name : t('auth.emailOAuth.signIn', { providerName: name })
|
||||
}
|
||||
|
||||
function startLogin(provider: EmailOAuthProvider): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
const affiliateCode = resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code)
|
||||
storeOAuthAffiliateCode(affiliateCode)
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const params = new URLSearchParams({ redirect: redirectTo })
|
||||
if (affiliateCode) {
|
||||
params.set('aff_code', affiliateCode)
|
||||
}
|
||||
const startURL = `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||
window.location.href = startURL
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.61 7.61 0 0 1 8 3.86c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.1A6.61 6.61 0 0 1 5.5 12c0-.73.12-1.43.34-2.1V7.06H2.18A10.96 10.96 0 0 0 1 12c0 1.77.42 3.45 1.18 4.94l3.66-2.84z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06L5.84 9.9C6.71 7.31 9.14 5.38 12 5.38z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
query: {} as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
const locationState = vi.hoisted(() => ({
|
||||
current: { href: 'http://localhost/register?aff=AFF123' } as { href: string },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'auth.emailOAuth.signIn') {
|
||||
return `使用 ${params?.providerName ?? ''} 登录`
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('EmailOAuthButtons', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' }
|
||||
locationState.current = { href: 'http://localhost/register?aff=AFF123' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: locationState.current,
|
||||
})
|
||||
window.localStorage.clear()
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('passes the affiliate code to the email oauth start URL', async () => {
|
||||
const wrapper = mount(EmailOAuthButtons, {
|
||||
props: {
|
||||
githubEnabled: true,
|
||||
googleEnabled: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
GitHubMark: true,
|
||||
GoogleMark: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toBe(
|
||||
'/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123'
|
||||
)
|
||||
expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123')
|
||||
})
|
||||
|
||||
it('uses a full-width descriptive button when only GitHub is enabled', () => {
|
||||
const wrapper = mount(EmailOAuthButtons, {
|
||||
props: {
|
||||
githubEnabled: true,
|
||||
googleEnabled: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
GitHubMark: true,
|
||||
GoogleMark: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.grid').classes()).not.toContain('sm:grid-cols-2')
|
||||
expect(wrapper.get('button').text()).toContain('使用 GitHub 登录')
|
||||
})
|
||||
|
||||
it('uses compact labels and two columns when GitHub and Google are both enabled', () => {
|
||||
const wrapper = mount(EmailOAuthButtons, {
|
||||
props: {
|
||||
githubEnabled: true,
|
||||
googleEnabled: true,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
GitHubMark: true,
|
||||
GoogleMark: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.grid').classes()).toContain('sm:grid-cols-2')
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].text()).toContain('GitHub')
|
||||
expect(buttons[0].text()).not.toContain('使用 GitHub 登录')
|
||||
expect(buttons[1].text()).toContain('Google')
|
||||
expect(buttons[1].text()).not.toContain('使用 Google 登录')
|
||||
})
|
||||
})
|
||||
@@ -263,7 +263,9 @@ const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
|
||||
email: t('profile.authBindings.providers.email'),
|
||||
linuxdo: t('profile.authBindings.providers.linuxdo'),
|
||||
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
||||
wechat: t('profile.authBindings.providers.wechat')
|
||||
wechat: t('profile.authBindings.providers.wechat'),
|
||||
github: 'GitHub',
|
||||
google: 'Google'
|
||||
}))
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
@@ -272,7 +274,13 @@ function formatCurrency(value: number): string {
|
||||
|
||||
function normalizeProvider(value: string): UserAuthProvider | null {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') {
|
||||
if (
|
||||
normalized === 'email' ||
|
||||
normalized === 'linuxdo' ||
|
||||
normalized === 'wechat' ||
|
||||
normalized === 'github' ||
|
||||
normalized === 'google'
|
||||
) {
|
||||
return normalized
|
||||
}
|
||||
if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) {
|
||||
|
||||
@@ -472,6 +472,9 @@ export default {
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||
},
|
||||
emailOAuth: {
|
||||
signIn: 'Continue with {providerName}'
|
||||
},
|
||||
oidc: {
|
||||
signIn: 'Continue with {providerName}',
|
||||
callbackTitle: 'Signing you in with {providerName}',
|
||||
@@ -531,6 +534,8 @@ export default {
|
||||
oauth: {
|
||||
callbackTitle: 'OAuth Callback',
|
||||
callbackHint: 'Copy the code and state back to the admin authorization flow when needed.',
|
||||
invalidCallbackTitle: 'Invalid sign-in callback',
|
||||
invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.',
|
||||
code: 'Code',
|
||||
state: 'State',
|
||||
fullUrl: 'Full URL'
|
||||
|
||||
@@ -471,6 +471,9 @@ export default {
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||
},
|
||||
emailOAuth: {
|
||||
signIn: '使用 {providerName} 登录'
|
||||
},
|
||||
oidc: {
|
||||
signIn: '使用 {providerName} 登录',
|
||||
callbackTitle: '正在完成 {providerName} 登录',
|
||||
@@ -529,6 +532,8 @@ export default {
|
||||
oauth: {
|
||||
callbackTitle: 'OAuth 回调',
|
||||
callbackHint: '按需将授权码和状态值复制回后台授权流程。',
|
||||
invalidCallbackTitle: '无效的登录回调',
|
||||
invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。',
|
||||
code: '授权码',
|
||||
state: '状态',
|
||||
fullUrl: '完整URL'
|
||||
|
||||
@@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/auth/callback',
|
||||
name: 'OAuthCallback',
|
||||
alias: '/auth/oauth/callback',
|
||||
component: () => import('@/views/auth/OAuthCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
|
||||
@@ -347,6 +347,8 @@ export const useAppStore = defineStore('app', () => {
|
||||
wechat_oauth_mobile_enabled: false,
|
||||
oidc_oauth_enabled: false,
|
||||
oidc_oauth_provider_name: 'OIDC',
|
||||
github_oauth_enabled: false,
|
||||
google_oauth_enabled: false,
|
||||
backend_mode_enabled: false,
|
||||
version: siteVersion.value,
|
||||
balance_low_notify_enabled: false,
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface NotifyEmailEntry {
|
||||
|
||||
// ==================== User & Auth Types ====================
|
||||
|
||||
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat'
|
||||
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google'
|
||||
|
||||
export interface UserAuthBindingStatus {
|
||||
bound?: boolean
|
||||
@@ -208,6 +208,8 @@ export interface PublicSettings {
|
||||
wechat_oauth_mobile_enabled?: boolean
|
||||
oidc_oauth_enabled: boolean
|
||||
oidc_oauth_provider_name: string
|
||||
github_oauth_enabled: boolean
|
||||
google_oauth_enabled: boolean
|
||||
backend_mode_enabled: boolean
|
||||
version: string
|
||||
balance_low_notify_enabled: boolean
|
||||
|
||||
@@ -1752,6 +1752,232 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitHub / Google 邮箱快捷登录 -->
|
||||
<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">
|
||||
{{ localText("邮箱快捷登录", "Email OAuth Sign-in") }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
"开启 GitHub 或 Google 邮箱授权登录后,系统会读取已验证邮箱,存在则直接登录,不存在则自动注册。",
|
||||
"After GitHub or Google email OAuth is enabled, the system reads a verified email, signs in matching users, and auto-registers missing users.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-700">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||
GitHub
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
"GitHub OAuth App 需要 read:user user:email 权限,回调地址填写下方后端地址。",
|
||||
"GitHub OAuth App needs read:user user:email scopes. Use the backend callback URL below.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.github_oauth_enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.github_oauth_enabled" class="mt-4 space-y-4">
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
||||
<template v-if="isZhLocale">
|
||||
开通引导:GitHub Settings → Developer settings →
|
||||
<a
|
||||
data-testid="github-oauth-apps-guide-link"
|
||||
href="https://github.com/settings/developers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>OAuth Apps</a>
|
||||
→ New OAuth App;Homepage URL 填站点域名,Authorization callback URL 填下面的后端回调地址。
|
||||
</template>
|
||||
<template v-else>
|
||||
Setup guide: GitHub Settings → Developer settings →
|
||||
<a
|
||||
data-testid="github-oauth-apps-guide-link"
|
||||
href="https://github.com/settings/developers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
|
||||
>OAuth Apps</a>
|
||||
→ New OAuth App. Use your site origin as Homepage URL and the backend callback URL below as Authorization callback URL.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input
|
||||
v-model="form.github_oauth_client_id"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="GitHub OAuth Client ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input
|
||||
v-model="form.github_oauth_client_secret"
|
||||
type="password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="
|
||||
form.github_oauth_client_secret_configured
|
||||
? localText('密钥已配置,留空以保留当前值。', 'Secret configured. Leave empty to keep the current value.')
|
||||
: 'GitHub OAuth Client Secret'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ localText("后端回调地址", "Backend Callback URL") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.github_oauth_redirect_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="https://your-domain.com/api/v1/auth/oauth/github/callback"
|
||||
/>
|
||||
<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="setAndCopyEmailOAuthRedirectUrl('github')"
|
||||
>
|
||||
{{ localText("生成并复制", "Generate and copy") }}
|
||||
</button>
|
||||
<code
|
||||
v-if="githubOAuthRedirectUrlSuggestion"
|
||||
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"
|
||||
>
|
||||
{{ githubOAuthRedirectUrlSuggestion }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ localText("前端回跳地址", "Frontend Callback URL") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.github_oauth_frontend_redirect_url"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="/auth/oauth/callback"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-700">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||
Google
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
"Google OAuth 客户端需要 openid email profile 范围,并在凭据里登记后端回调地址。",
|
||||
"Google OAuth client needs openid email profile scopes and the backend callback URL registered in credentials.",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.google_oauth_enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.google_oauth_enabled" class="mt-4 space-y-4">
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300">
|
||||
{{
|
||||
localText(
|
||||
"开通引导:Google Cloud Console → APIs & Services → OAuth consent screen 完成同意屏幕;Credentials → Create Credentials → OAuth client ID,类型选择 Web application,并把下面地址加入 Authorized redirect URIs。",
|
||||
"Setup guide: Google Cloud Console → APIs & Services → OAuth consent screen, then Credentials → Create Credentials → OAuth client ID, choose Web application, and add the URL below to Authorized redirect URIs.",
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input
|
||||
v-model="form.google_oauth_client_id"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="Google OAuth Client ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input
|
||||
v-model="form.google_oauth_client_secret"
|
||||
type="password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="
|
||||
form.google_oauth_client_secret_configured
|
||||
? localText('密钥已配置,留空以保留当前值。', 'Secret configured. Leave empty to keep the current value.')
|
||||
: 'Google OAuth Client Secret'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ localText("后端回调地址", "Backend Callback URL") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.google_oauth_redirect_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="https://your-domain.com/api/v1/auth/oauth/google/callback"
|
||||
/>
|
||||
<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="setAndCopyEmailOAuthRedirectUrl('google')"
|
||||
>
|
||||
{{ localText("生成并复制", "Generate and copy") }}
|
||||
</button>
|
||||
<code
|
||||
v-if="googleOAuthRedirectUrlSuggestion"
|
||||
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"
|
||||
>
|
||||
{{ googleOAuthRedirectUrlSuggestion }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ localText("前端回跳地址", "Frontend Callback URL") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.google_oauth_frontend_redirect_url"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="/auth/oauth/callback"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WeChat Connect OAuth 登录 -->
|
||||
<div class="card">
|
||||
<div
|
||||
@@ -5646,9 +5872,10 @@ import {
|
||||
const { t, locale } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const adminSettingsStore = useAdminSettingsStore();
|
||||
const isZhLocale = computed(() => locale.value.startsWith("zh"));
|
||||
|
||||
function localText(zh: string, en: string): string {
|
||||
return locale.value.startsWith("zh") ? zh : en;
|
||||
return isZhLocale.value ? zh : en;
|
||||
}
|
||||
|
||||
const paymentGuideHref = computed(() =>
|
||||
@@ -5796,6 +6023,8 @@ type SettingsForm = Omit<
|
||||
wechat_connect_mp_enabled: boolean;
|
||||
wechat_connect_mobile_enabled: boolean;
|
||||
oidc_connect_client_secret: string;
|
||||
github_oauth_client_secret: string;
|
||||
google_oauth_client_secret: string;
|
||||
force_email_on_third_party_signup: boolean;
|
||||
openai_advanced_scheduler_enabled: boolean;
|
||||
};
|
||||
@@ -5926,6 +6155,19 @@ const form = reactive<SettingsForm>({
|
||||
oidc_connect_userinfo_email_path: "",
|
||||
oidc_connect_userinfo_id_path: "",
|
||||
oidc_connect_userinfo_username_path: "",
|
||||
// GitHub / Google 邮箱快捷登录
|
||||
github_oauth_enabled: false,
|
||||
github_oauth_client_id: "",
|
||||
github_oauth_client_secret: "",
|
||||
github_oauth_client_secret_configured: false,
|
||||
github_oauth_redirect_url: "",
|
||||
github_oauth_frontend_redirect_url: "/auth/oauth/callback",
|
||||
google_oauth_enabled: false,
|
||||
google_oauth_client_id: "",
|
||||
google_oauth_client_secret: "",
|
||||
google_oauth_client_secret_configured: false,
|
||||
google_oauth_redirect_url: "",
|
||||
google_oauth_frontend_redirect_url: "/auth/oauth/callback",
|
||||
// Model fallback
|
||||
enable_model_fallback: false,
|
||||
fallback_model_anthropic: "claude-3-5-sonnet-20241022",
|
||||
@@ -5991,6 +6233,22 @@ const authSourceDefaultsMeta = computed(() => [
|
||||
title: t("admin.settings.authSourceDefaults.sources.wechat.title"),
|
||||
description: t("admin.settings.authSourceDefaults.sources.wechat.description"),
|
||||
},
|
||||
{
|
||||
source: "github" as AuthSourceType,
|
||||
title: "GitHub",
|
||||
description: localText(
|
||||
"通过 GitHub 已验证邮箱首次注册或首次绑定时应用。",
|
||||
"Applied on first signup or first bind through a verified GitHub email.",
|
||||
),
|
||||
},
|
||||
{
|
||||
source: "google" as AuthSourceType,
|
||||
title: "Google",
|
||||
description: localText(
|
||||
"通过 Google 已验证邮箱首次注册或首次绑定时应用。",
|
||||
"Applied on first signup or first bind through a verified Google email.",
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
// Proxies for web search emulation ProxySelector
|
||||
@@ -6298,6 +6556,42 @@ async function setAndCopyLinuxdoRedirectUrl() {
|
||||
);
|
||||
}
|
||||
|
||||
type EmailOAuthProvider = "github" | "google";
|
||||
|
||||
const githubOAuthRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
const origin =
|
||||
window.location.origin ||
|
||||
`${window.location.protocol}//${window.location.host}`;
|
||||
return `${origin}/api/v1/auth/oauth/github/callback`;
|
||||
});
|
||||
|
||||
const googleOAuthRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
const origin =
|
||||
window.location.origin ||
|
||||
`${window.location.protocol}//${window.location.host}`;
|
||||
return `${origin}/api/v1/auth/oauth/google/callback`;
|
||||
});
|
||||
|
||||
async function setAndCopyEmailOAuthRedirectUrl(provider: EmailOAuthProvider) {
|
||||
const url =
|
||||
provider === "github"
|
||||
? githubOAuthRedirectUrlSuggestion.value
|
||||
: googleOAuthRedirectUrlSuggestion.value;
|
||||
if (!url) return;
|
||||
|
||||
if (provider === "github") {
|
||||
form.github_oauth_redirect_url = url;
|
||||
} else {
|
||||
form.google_oauth_redirect_url = url;
|
||||
}
|
||||
await copyToClipboard(
|
||||
url,
|
||||
localText("回调地址已写入并复制。", "Callback URL set and copied."),
|
||||
);
|
||||
}
|
||||
|
||||
const wechatRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
const origin =
|
||||
@@ -6488,6 +6782,8 @@ async function loadSettings() {
|
||||
smtpPasswordManuallyEdited.value = false;
|
||||
form.turnstile_secret_key = "";
|
||||
form.linuxdo_connect_client_secret = "";
|
||||
form.github_oauth_client_secret = "";
|
||||
form.google_oauth_client_secret = "";
|
||||
form.wechat_connect_app_secret = "";
|
||||
form.wechat_connect_open_app_secret = "";
|
||||
form.wechat_connect_mp_app_secret = "";
|
||||
@@ -6846,6 +7142,20 @@ async function saveSettings() {
|
||||
oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path,
|
||||
oidc_connect_userinfo_username_path:
|
||||
form.oidc_connect_userinfo_username_path,
|
||||
github_oauth_enabled: form.github_oauth_enabled,
|
||||
github_oauth_client_id: form.github_oauth_client_id,
|
||||
github_oauth_client_secret:
|
||||
form.github_oauth_client_secret || undefined,
|
||||
github_oauth_redirect_url: form.github_oauth_redirect_url,
|
||||
github_oauth_frontend_redirect_url:
|
||||
form.github_oauth_frontend_redirect_url,
|
||||
google_oauth_enabled: form.google_oauth_enabled,
|
||||
google_oauth_client_id: form.google_oauth_client_id,
|
||||
google_oauth_client_secret:
|
||||
form.google_oauth_client_secret || undefined,
|
||||
google_oauth_redirect_url: form.google_oauth_redirect_url,
|
||||
google_oauth_frontend_redirect_url:
|
||||
form.google_oauth_frontend_redirect_url,
|
||||
enable_model_fallback: form.enable_model_fallback,
|
||||
fallback_model_anthropic: form.fallback_model_anthropic,
|
||||
fallback_model_openai: form.fallback_model_openai,
|
||||
@@ -6960,6 +7270,8 @@ async function saveSettings() {
|
||||
smtpPasswordManuallyEdited.value = false;
|
||||
form.turnstile_secret_key = "";
|
||||
form.linuxdo_connect_client_secret = "";
|
||||
form.github_oauth_client_secret = "";
|
||||
form.google_oauth_client_secret = "";
|
||||
form.wechat_connect_app_secret = "";
|
||||
form.wechat_connect_open_app_secret = "";
|
||||
form.wechat_connect_mp_app_secret = "";
|
||||
|
||||
@@ -817,6 +817,24 @@ describe("admin SettingsView wechat connect controls", () => {
|
||||
).toBe("/auth/wechat/callback");
|
||||
});
|
||||
|
||||
it("links GitHub OAuth Apps guide to GitHub developer settings", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
github_oauth_enabled: true,
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openSecurityTab(wrapper);
|
||||
|
||||
const link = wrapper.get('[data-testid="github-oauth-apps-guide-link"]');
|
||||
expect(link.text()).toContain("OAuth Apps");
|
||||
expect(link.attributes("href")).toBe("https://github.com/settings/developers");
|
||||
expect(link.attributes("target")).toBe("_blank");
|
||||
expect(link.attributes("rel")).toContain("noopener");
|
||||
});
|
||||
|
||||
it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
|
||||
@@ -10,33 +10,6 @@
|
||||
{{ t('auth.signInToAccount') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
: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">
|
||||
<!-- Email Input -->
|
||||
@@ -144,6 +117,40 @@
|
||||
<Icon v-else name="login" size="md" class="mr-2" />
|
||||
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
|
||||
</button>
|
||||
|
||||
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||
<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>
|
||||
|
||||
<EmailOAuthButtons
|
||||
:disabled="isLoading"
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
:google-enabled="googleOAuthEnabled"
|
||||
:show-divider="false"
|
||||
/>
|
||||
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:show-divider="false"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -180,6 +187,7 @@ import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
@@ -210,6 +218,8 @@ const wechatOAuthEnabled = ref<boolean>(false)
|
||||
const backendModeEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const githubOAuthEnabled = ref<boolean>(false)
|
||||
const googleOAuthEnabled = ref<boolean>(false)
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
|
||||
// Turnstile
|
||||
@@ -237,6 +247,16 @@ const validationToastMessage = computed(
|
||||
() => errors.email || errors.password || errors.turnstile || ''
|
||||
)
|
||||
|
||||
const showOAuthLogin = computed(
|
||||
() =>
|
||||
!backendModeEnabled.value &&
|
||||
(linuxdoOAuthEnabled.value ||
|
||||
wechatOAuthEnabled.value ||
|
||||
oidcOAuthEnabled.value ||
|
||||
githubOAuthEnabled.value ||
|
||||
googleOAuthEnabled.value)
|
||||
)
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
appStore.showError(value)
|
||||
@@ -263,6 +283,8 @@ onMounted(async () => {
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
githubOAuthEnabled.value = settings.github_oauth_enabled
|
||||
googleOAuthEnabled.value = settings.google_oauth_enabled
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
passwordResetEnabled.value = settings.password_reset_enabled
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,60 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="card p-6">
|
||||
<div v-if="isProcessing" class="card p-6 text-center">
|
||||
<div class="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"></div>
|
||||
<h1 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauth.callbackTitle') }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('auth.oauth.callbackHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="needsInvitation" class="card p-6">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oidc.callbackTitle', { providerName }) }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
type="button"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oidc.completeRegistration') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="invalidCallback" class="card p-6 text-center">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauth.invalidCallbackTitle') }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('auth.oauth.invalidCallbackHint') }}
|
||||
</p>
|
||||
<button class="btn btn-primary mt-6" type="button" @click="router.replace('/login')">
|
||||
{{ t('auth.backToLogin') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-6">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauth.callbackTitle') }}
|
||||
</h1>
|
||||
@@ -56,16 +109,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import { apiClient } from '@/api/client'
|
||||
import {
|
||||
exchangePendingOAuthCompletion,
|
||||
persistOAuthTokenContext,
|
||||
type OAuthTokenResponse
|
||||
} from '@/api/auth'
|
||||
import {
|
||||
clearAllAffiliateReferralCodes,
|
||||
loadOAuthAffiliateCode,
|
||||
oauthAffiliatePayload
|
||||
} from '@/utils/oauthAffiliate'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const isProcessing = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const needsInvitation = ref(false)
|
||||
const invitationCode = ref('')
|
||||
const invitationError = ref('')
|
||||
const pendingProvider = ref<'github' | 'google'>('github')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const invalidCallback = ref(false)
|
||||
|
||||
type EmailOAuthPendingCompletion = Partial<OAuthTokenResponse> & {
|
||||
error?: string
|
||||
provider?: string
|
||||
redirect?: string
|
||||
}
|
||||
|
||||
const code = computed(() => (route.query.code as string) || '')
|
||||
const state = computed(() => (route.query.state as string) || '')
|
||||
@@ -77,6 +157,137 @@ const fullUrl = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.location.href
|
||||
})
|
||||
const providerName = computed(() =>
|
||||
pendingProvider.value === 'google' ? 'Google' : 'GitHub'
|
||||
)
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||
return new URLSearchParams(hash)
|
||||
}
|
||||
|
||||
function readTokenResponse(params: URLSearchParams): OAuthTokenResponse | null {
|
||||
const accessToken = params.get('access_token')?.trim() || ''
|
||||
if (!accessToken) return null
|
||||
|
||||
const response: OAuthTokenResponse = { access_token: accessToken }
|
||||
const refreshToken = params.get('refresh_token')?.trim() || ''
|
||||
if (refreshToken) response.refresh_token = refreshToken
|
||||
const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10)
|
||||
if (Number.isFinite(expiresIn) && expiresIn > 0) response.expires_in = expiresIn
|
||||
const tokenType = params.get('token_type')?.trim() || ''
|
||||
if (tokenType) response.token_type = tokenType
|
||||
return response
|
||||
}
|
||||
|
||||
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 finalizeTokenResponse(tokenResponse: OAuthTokenResponse, redirect: string) {
|
||||
persistOAuthTokenContext(tokenResponse)
|
||||
await authStore.setToken(tokenResponse.access_token)
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(sanitizeRedirectPath(redirect))
|
||||
}
|
||||
|
||||
function hasOAuthTokenResponse(value: Partial<OAuthTokenResponse>): value is OAuthTokenResponse {
|
||||
return typeof value.access_token === 'string' && value.access_token.trim() !== ''
|
||||
}
|
||||
|
||||
async function resumePendingEmailOAuth() {
|
||||
isProcessing.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion() as EmailOAuthPendingCompletion
|
||||
const completionRedirect = completion.redirect || '/dashboard'
|
||||
if (hasOAuthTokenResponse(completion)) {
|
||||
await finalizeTokenResponse(completion, completionRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
const provider = String(completion.provider || '').toLowerCase()
|
||||
if (provider === 'github' || provider === 'google') {
|
||||
pendingProvider.value = provider
|
||||
}
|
||||
redirectTo.value = sanitizeRedirectPath(completionRedirect)
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
appStore.showError(completion.error || t('auth.loginFailed'))
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||
const message = err.response?.data?.message || err.message || t('auth.loginFailed')
|
||||
appStore.showError(message)
|
||||
invalidCallback.value = true
|
||||
} finally {
|
||||
if (!needsInvitation.value) {
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
const code = invitationCode.value.trim()
|
||||
if (!code) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>(
|
||||
`/auth/oauth/${pendingProvider.value}/complete-registration`,
|
||||
{
|
||||
invitation_code: code,
|
||||
...oauthAffiliatePayload(loadOAuthAffiliateCode())
|
||||
}
|
||||
)
|
||||
await finalizeTokenResponse(data, 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 () => {
|
||||
const params = parseFragmentParams()
|
||||
const tokenResponse = readTokenResponse(params)
|
||||
const fragmentError = params.get('error') || ''
|
||||
const fragmentErrorDescription =
|
||||
params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (fragmentError) {
|
||||
appStore.showError(fragmentErrorDescription || fragmentError)
|
||||
return
|
||||
}
|
||||
if (!tokenResponse) {
|
||||
if (route.path === '/auth/oauth/callback') {
|
||||
await resumePendingEmailOAuth()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isProcessing.value = true
|
||||
try {
|
||||
await finalizeTokenResponse(tokenResponse, params.get('redirect') || '/dashboard')
|
||||
} catch (error: unknown) {
|
||||
const message = (error as { message?: string })?.message || t('auth.loginFailed')
|
||||
appStore.showError(message)
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
error,
|
||||
|
||||
@@ -11,35 +11,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:aff-code="formData.aff_code"
|
||||
: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
|
||||
v-if="!registrationEnabled && settingsLoaded"
|
||||
@@ -256,7 +227,46 @@
|
||||
: t('auth.createAccount')
|
||||
}}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
|
||||
<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>
|
||||
|
||||
<EmailOAuthButtons
|
||||
:disabled="isLoading"
|
||||
:aff-code="formData.aff_code"
|
||||
:github-enabled="githubOAuthEnabled"
|
||||
:google-enabled="googleOAuthEnabled"
|
||||
:show-divider="false"
|
||||
/>
|
||||
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:provider-name="oidcOAuthProviderName"
|
||||
:aff-code="formData.aff_code"
|
||||
:show-divider="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -282,6 +292,7 @@ import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
@@ -330,6 +341,8 @@ const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const githubOAuthEnabled = ref<boolean>(false)
|
||||
const googleOAuthEnabled = ref<boolean>(false)
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
|
||||
// Turnstile
|
||||
@@ -380,6 +393,15 @@ const validationToastMessage = computed(() =>
|
||||
''
|
||||
)
|
||||
|
||||
const showOAuthLogin = computed(
|
||||
() =>
|
||||
linuxdoOAuthEnabled.value ||
|
||||
wechatOAuthEnabled.value ||
|
||||
oidcOAuthEnabled.value ||
|
||||
githubOAuthEnabled.value ||
|
||||
googleOAuthEnabled.value
|
||||
)
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
appStore.showError(value)
|
||||
@@ -412,6 +434,8 @@ onMounted(async () => {
|
||||
wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings)
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
githubOAuthEnabled.value = settings.github_oauth_enabled
|
||||
googleOAuthEnabled.value = settings.google_oauth_enabled
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
settings.registration_email_suffix_whitelist || []
|
||||
)
|
||||
|
||||
@@ -2,16 +2,34 @@ import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OAuthCallbackView from '@/views/auth/OAuthCallbackView.vue'
|
||||
|
||||
const { routeState, showErrorMock, copyToClipboardMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
routeState,
|
||||
routerReplaceMock,
|
||||
showErrorMock,
|
||||
showSuccessMock,
|
||||
setTokenMock,
|
||||
copyToClipboardMock,
|
||||
exchangePendingOAuthCompletionMock,
|
||||
apiPostMock,
|
||||
} = vi.hoisted(() => ({
|
||||
routeState: {
|
||||
path: '/auth/callback',
|
||||
query: {} as Record<string, unknown>,
|
||||
},
|
||||
routerReplaceMock: vi.fn(),
|
||||
showErrorMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
setTokenMock: vi.fn(),
|
||||
copyToClipboardMock: vi.fn(),
|
||||
exchangePendingOAuthCompletionMock: vi.fn(),
|
||||
apiPostMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({
|
||||
replace: (...args: any[]) => routerReplaceMock(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -21,11 +39,30 @@ vi.mock('vue-i18n', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
setToken: (...args: any[]) => setTokenMock(...args),
|
||||
}),
|
||||
useAppStore: () => ({
|
||||
showError: (...args: any[]) => showErrorMock(...args),
|
||||
showSuccess: (...args: any[]) => showSuccessMock(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post: (...args: any[]) => apiPostMock(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||
return {
|
||||
...actual,
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
||||
persistOAuthTokenContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args),
|
||||
@@ -34,9 +71,17 @@ vi.mock('@/composables/useClipboard', () => ({
|
||||
|
||||
describe('OAuthCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
routeState.path = '/auth/callback'
|
||||
routeState.query = {}
|
||||
window.location.hash = ''
|
||||
routerReplaceMock.mockReset()
|
||||
showErrorMock.mockReset()
|
||||
showSuccessMock.mockReset()
|
||||
setTokenMock.mockReset()
|
||||
copyToClipboardMock.mockReset()
|
||||
exchangePendingOAuthCompletionMock.mockReset()
|
||||
apiPostMock.mockReset()
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('renders localized callback copy actions', () => {
|
||||
@@ -65,4 +110,44 @@ describe('OAuthCallbackView', () => {
|
||||
expect(wrapper.text()).not.toContain('oauth failed')
|
||||
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render manual copy fields for direct email oauth callback visits', async () => {
|
||||
routeState.path = '/auth/oauth/callback'
|
||||
exchangePendingOAuthCompletionMock.mockRejectedValue(new Error('pending session not found'))
|
||||
|
||||
const wrapper = mount(OAuthCallbackView)
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(exchangePendingOAuthCompletionMock).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('auth.oauth.invalidCallbackTitle')
|
||||
expect(wrapper.text()).toContain('auth.oauth.invalidCallbackHint')
|
||||
expect(wrapper.find('input[readonly]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('submits stored affiliate code when completing invited email oauth registration', async () => {
|
||||
routeState.path = '/auth/oauth/callback'
|
||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
provider: 'google',
|
||||
redirect: '/dashboard',
|
||||
})
|
||||
apiPostMock.mockResolvedValue({
|
||||
data: {
|
||||
access_token: 'token-1',
|
||||
},
|
||||
})
|
||||
window.sessionStorage.setItem('oauth_aff_code', 'AFF456')
|
||||
|
||||
const wrapper = mount(OAuthCallbackView)
|
||||
await vi.dynamicImportSettled()
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.setValue('INVITE456')
|
||||
await wrapper.findAll('button').at(0)?.trigger('click')
|
||||
|
||||
expect(apiPostMock).toHaveBeenCalledWith('/auth/oauth/google/complete-registration', {
|
||||
invitation_code: 'INVITE456',
|
||||
aff_code: 'AFF456',
|
||||
})
|
||||
expect(setTokenMock).toHaveBeenCalledWith('token-1')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user