feat: 增加 GitHub 和 Google 邮箱快捷登录

This commit is contained in:
lyen1688
2026-05-06 16:06:11 +08:00
parent a1106e8167
commit af550fa64e
35 changed files with 2656 additions and 74 deletions
+313 -1
View File
@@ -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 AppHomepage 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();
+49 -27
View File
@@ -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) {
+215 -4
View File
@@ -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,
+53 -29
View File
@@ -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')
})
})