@@ -5875,7 +6077,12 @@ import type {
WebSearchProviderConfig,
WebSearchTestResult,
} from "@/api/admin/settings";
-import type { AdminGroup, Proxy, NotifyEmailEntry } from "@/types";
+import type {
+ AdminGroup,
+ LoginAgreementDocument,
+ NotifyEmailEntry,
+ Proxy,
+} from "@/types";
import type { ProviderInstance } from "@/types/payment";
import AppLayout from "@/components/layout/AppLayout.vue";
import Icon from "@/components/icons/Icon.vue";
@@ -5925,6 +6132,7 @@ const paymentMethodsHref = computed(() =>
type SettingsTab =
| "general"
+ | "agreement"
| "features"
| "security"
| "users"
@@ -5935,6 +6143,7 @@ type SettingsTab =
const activeTab = ref
("general");
const settingsTabs = [
{ key: "general" as SettingsTab, icon: "home" as const },
+ { key: "agreement" as SettingsTab, icon: "document" as const },
{ key: "features" as SettingsTab, icon: "bolt" as const },
{ key: "security" as SettingsTab, icon: "shield" as const },
{ key: "users" as SettingsTab, icon: "user" as const },
@@ -6029,6 +6238,49 @@ const tablePageSizeMin = 5;
const tablePageSizeMax = 1000;
const tablePageSizeDefault = 20;
+function defaultLoginAgreementDocuments(): LoginAgreementDocument[] {
+ return [
+ {
+ id: "terms",
+ title: "服务条款",
+ content_md: "",
+ },
+ {
+ id: "usage-policy",
+ title: "使用政策",
+ content_md: "",
+ },
+ {
+ id: "supported-regions",
+ title: "支持的国家和地区",
+ content_md: "",
+ },
+ {
+ id: "service-specific-terms",
+ title: "服务特定条款",
+ content_md: "",
+ },
+ ];
+}
+
+function normalizeLoginAgreementDocumentId(raw: string): string {
+ return raw
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/[-_]{2,}/g, "-")
+ .replace(/^[-_]+|[-_]+$/g, "");
+}
+
+function loginAgreementRoutePath(
+ doc: LoginAgreementDocument,
+ index: number,
+): string {
+ const id =
+ normalizeLoginAgreementDocumentId(doc.id || doc.title) || `doc-${index + 1}`;
+ return `/legal/${id}`;
+}
+
interface DefaultSubscriptionGroupOption {
value: number;
label: string;
@@ -6071,6 +6323,10 @@ const form = reactive({
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
+ login_agreement_enabled: false,
+ login_agreement_mode: "modal",
+ login_agreement_updated_at: "2026-03-31",
+ login_agreement_documents: defaultLoginAgreementDocuments(),
default_balance: 0,
affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
@@ -6753,6 +7009,43 @@ function removeEndpoint(index: number) {
form.custom_endpoints.splice(index, 1);
}
+function addLoginAgreementDocument() {
+ form.login_agreement_documents.push({
+ id: `custom-${Date.now().toString(36)}`,
+ title: "",
+ content_md: "",
+ });
+}
+
+function removeLoginAgreementDocument(index: number) {
+ form.login_agreement_documents.splice(index, 1);
+}
+
+function normalizeLoginAgreementDocumentsForSave(): LoginAgreementDocument[] {
+ return form.login_agreement_documents
+ .map((doc, index) => ({
+ id:
+ normalizeLoginAgreementDocumentId(doc.id || doc.title) ||
+ `doc-${index + 1}`,
+ title: doc.title.trim(),
+ content_md: doc.content_md.trim(),
+ }))
+ .filter((doc) => doc.title || doc.content_md);
+}
+
+function findDuplicateLoginAgreementDocumentId(
+ documents: LoginAgreementDocument[],
+): string | null {
+ const seen = new Set();
+ for (const doc of documents) {
+ if (seen.has(doc.id)) {
+ return doc.id;
+ }
+ seen.add(doc.id);
+ }
+ return null;
+}
+
function formatTablePageSizeOptions(options: number[]): string {
return options.join(", ");
}
@@ -6797,6 +7090,19 @@ async function loadSettings() {
(form as Record)[key] = value;
}
}
+ form.login_agreement_mode =
+ settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
+ form.login_agreement_updated_at =
+ settings.login_agreement_updated_at || "2026-03-31";
+ form.login_agreement_documents =
+ Array.isArray(settings.login_agreement_documents) &&
+ settings.login_agreement_documents.length > 0
+ ? settings.login_agreement_documents.map((doc) => ({
+ id: doc.id || "",
+ title: doc.title || "",
+ content_md: doc.content_md || "",
+ }))
+ : defaultLoginAgreementDocuments();
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
form.backend_mode_enabled = settings.backend_mode_enabled;
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
@@ -7008,6 +7314,44 @@ async function saveSettings() {
form.table_default_page_size = normalizedTableDefaultPageSize;
form.table_page_size_options = normalizedTablePageSizeOptions;
+ const normalizedLoginAgreementDocuments =
+ normalizeLoginAgreementDocumentsForSave();
+ if (form.login_agreement_enabled && normalizedLoginAgreementDocuments.length === 0) {
+ appStore.showError(
+ localText(
+ "启用登录条款确认时,至少需要保留一份文档。",
+ "At least one document is required when login agreement is enabled.",
+ ),
+ );
+ return;
+ }
+ const emptyTitleDocument = normalizedLoginAgreementDocuments.find(
+ (doc) => !doc.title,
+ );
+ if (emptyTitleDocument) {
+ appStore.showError(
+ localText(
+ "登录条款文档名称不能为空。",
+ "Login agreement document title cannot be empty.",
+ ),
+ );
+ return;
+ }
+ const duplicateLoginAgreementDocumentId =
+ findDuplicateLoginAgreementDocumentId(normalizedLoginAgreementDocuments);
+ if (duplicateLoginAgreementDocumentId) {
+ appStore.showError(
+ localText(
+ `登录条款文档路由不能重复:/legal/${duplicateLoginAgreementDocumentId}`,
+ `Login agreement document routes cannot be duplicated: /legal/${duplicateLoginAgreementDocumentId}`,
+ ),
+ );
+ return;
+ }
+ form.login_agreement_mode =
+ form.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
+ form.login_agreement_documents = normalizedLoginAgreementDocuments;
+
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
form.default_subscriptions,
);
@@ -7085,6 +7429,10 @@ async function saveSettings() {
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
+ login_agreement_enabled: form.login_agreement_enabled,
+ login_agreement_mode: form.login_agreement_mode,
+ login_agreement_updated_at: form.login_agreement_updated_at,
+ login_agreement_documents: form.login_agreement_documents,
default_balance: form.default_balance,
affiliate_rebate_rate: Math.min(
100,
diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue
index 9b3a6def..3e89b079 100644
--- a/frontend/src/views/auth/LoginView.vue
+++ b/frontend/src/views/auth/LoginView.vue
@@ -28,7 +28,7 @@
required
autofocus
autocomplete="email"
- :disabled="isLoading"
+ :disabled="authActionDisabled"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
@@ -51,7 +51,7 @@
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
- :disabled="isLoading"
+ :disabled="authActionDisabled"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.passwordPlaceholder')"
@@ -59,6 +59,7 @@
@@ -91,7 +92,7 @@
+
+
@@ -188,16 +201,18 @@ 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 LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
-import type { TotpLoginResponse } from '@/types'
+import type { LoginAgreementDocument, TotpLoginResponse } from '@/types'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
+const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
// ==================== Router & Stores ====================
@@ -210,6 +225,7 @@ const appStore = useAppStore()
const isLoading = ref
(false)
const errorMessage = ref('')
const showPassword = ref(false)
+const publicSettingsLoaded = ref(false)
// Public settings
const turnstileEnabled = ref(false)
@@ -222,6 +238,13 @@ const oidcOAuthProviderName = ref('OIDC')
const githubOAuthEnabled = ref(false)
const googleOAuthEnabled = ref(false)
const passwordResetEnabled = ref(false)
+const loginAgreementEnabled = ref(false)
+const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
+const loginAgreementUpdatedAt = ref('')
+const loginAgreementRevision = ref('')
+const loginAgreementDocuments = ref([])
+const agreementAccepted = ref(false)
+const showAgreementModal = ref(false)
// Turnstile
const turnstileRef = ref | null>(null)
@@ -248,6 +271,14 @@ const validationToastMessage = computed(
() => errors.email || errors.password || errors.turnstile || ''
)
+const agreementGateActive = computed(
+ () => loginAgreementEnabled.value && !agreementAccepted.value
+)
+
+const authActionDisabled = computed(
+ () => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
+)
+
const showOAuthLogin = computed(
() =>
!backendModeEnabled.value &&
@@ -288,11 +319,78 @@ onMounted(async () => {
googleOAuthEnabled.value = settings.google_oauth_enabled
backendModeEnabled.value = settings.backend_mode_enabled
passwordResetEnabled.value = settings.password_reset_enabled
+ applyLoginAgreementSettings(settings)
} catch (error) {
console.error('Failed to load public settings:', error)
+ loginAgreementEnabled.value = false
+ agreementAccepted.value = true
+ } finally {
+ publicSettingsLoaded.value = true
}
})
+// ==================== Login Agreement ====================
+
+function applyLoginAgreementSettings(settings: {
+ login_agreement_enabled?: boolean
+ login_agreement_mode?: string
+ login_agreement_updated_at?: string
+ login_agreement_revision?: string
+ login_agreement_documents?: LoginAgreementDocument[]
+}): void {
+ const documents = Array.isArray(settings.login_agreement_documents)
+ ? settings.login_agreement_documents.filter((doc) => doc.title?.trim())
+ : []
+ loginAgreementDocuments.value = documents
+ loginAgreementEnabled.value = settings.login_agreement_enabled === true && documents.length > 0
+ loginAgreementMode.value = settings.login_agreement_mode === 'checkbox' ? 'checkbox' : 'modal'
+ loginAgreementUpdatedAt.value = settings.login_agreement_updated_at || ''
+ loginAgreementRevision.value =
+ settings.login_agreement_revision ||
+ `${loginAgreementUpdatedAt.value}:${documents.map((doc) => `${doc.id}:${doc.title}`).join('|')}`
+
+ agreementAccepted.value = !loginAgreementEnabled.value || hasAcceptedLoginAgreement(loginAgreementRevision.value)
+ showAgreementModal.value =
+ loginAgreementEnabled.value && !agreementAccepted.value && loginAgreementMode.value !== 'checkbox'
+}
+
+function hasAcceptedLoginAgreement(revision: string): boolean {
+ if (!revision) {
+ return false
+ }
+ try {
+ const raw = localStorage.getItem(LOGIN_AGREEMENT_STORAGE_KEY)
+ if (!raw) {
+ return false
+ }
+ const parsed = JSON.parse(raw) as { revision?: string }
+ return parsed.revision === revision
+ } catch {
+ return false
+ }
+}
+
+function acceptLoginAgreement(): void {
+ if (loginAgreementRevision.value) {
+ localStorage.setItem(
+ LOGIN_AGREEMENT_STORAGE_KEY,
+ JSON.stringify({
+ revision: loginAgreementRevision.value,
+ accepted_at: new Date().toISOString()
+ })
+ )
+ }
+ agreementAccepted.value = true
+ showAgreementModal.value = false
+}
+
+function rejectLoginAgreement(): void {
+ localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
+ agreementAccepted.value = false
+ showAgreementModal.value = false
+ appStore.showWarning('未同意最新条款前,无法输入账号密码或使用快捷登录。')
+}
+
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
@@ -320,6 +418,14 @@ function validateForm(): boolean {
let isValid = true
+ if (agreementGateActive.value) {
+ appStore.showWarning('请先阅读并同意最新条款后再登录。')
+ if (loginAgreementMode.value !== 'checkbox') {
+ showAgreementModal.value = true
+ }
+ return false
+ }
+
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue
index c9142424..fbd3716f 100644
--- a/frontend/src/views/auth/RegisterView.vue
+++ b/frontend/src/views/auth/RegisterView.vue
@@ -44,7 +44,7 @@
required
autofocus
autocomplete="email"
- :disabled="isLoading"
+ :disabled="registrationActionDisabled"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
@@ -67,13 +67,14 @@
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
- :disabled="isLoading"
+ :disabled="registrationActionDisabled"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.createPasswordPlaceholder')"
/>
@@ -99,7 +100,7 @@
id="invitation_code"
v-model="formData.invitation_code"
type="text"
- :disabled="isLoading"
+ :disabled="registrationActionDisabled"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
@@ -147,7 +148,7 @@
id="promo_code"
v-model="formData.promo_code"
type="text"
- :disabled="isLoading"
+ :disabled="registrationActionDisabled"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
@@ -192,10 +193,22 @@
/>
+
+
('OIDC')
const githubOAuthEnabled = ref(false)
const googleOAuthEnabled = ref(false)
const registrationEmailSuffixWhitelist = ref([])
+const loginAgreementEnabled = ref(false)
+const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
+const loginAgreementUpdatedAt = ref('')
+const loginAgreementRevision = ref('')
+const loginAgreementDocuments = ref([])
+const agreementAccepted = ref(false)
+const showAgreementModal = ref(false)
// Turnstile
const turnstileRef = ref | null>(null)
@@ -402,6 +425,14 @@ const showOAuthLogin = computed(
googleOAuthEnabled.value
)
+const agreementGateActive = computed(
+ () => loginAgreementEnabled.value && !agreementAccepted.value
+)
+
+const registrationActionDisabled = computed(
+ () => isLoading.value || !settingsLoaded.value || agreementGateActive.value
+)
+
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
@@ -439,6 +470,7 @@ onMounted(async () => {
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
+ applyLoginAgreementSettings(settings)
// Read promo code from URL parameter only if promo code is enabled
if (promoCodeEnabled.value) {
@@ -452,6 +484,8 @@ onMounted(async () => {
syncAffiliateReferralCode()
} catch (error) {
console.error('Failed to load public settings:', error)
+ loginAgreementEnabled.value = false
+ agreementAccepted.value = true
} finally {
settingsLoaded.value = true
}
@@ -473,6 +507,68 @@ onUnmounted(() => {
}
})
+// ==================== Login Agreement ====================
+
+function applyLoginAgreementSettings(settings: {
+ login_agreement_enabled?: boolean
+ login_agreement_mode?: string
+ login_agreement_updated_at?: string
+ login_agreement_revision?: string
+ login_agreement_documents?: LoginAgreementDocument[]
+}): void {
+ const documents = Array.isArray(settings.login_agreement_documents)
+ ? settings.login_agreement_documents.filter((doc) => doc.title?.trim())
+ : []
+ loginAgreementDocuments.value = documents
+ loginAgreementEnabled.value = settings.login_agreement_enabled === true && documents.length > 0
+ loginAgreementMode.value = settings.login_agreement_mode === 'checkbox' ? 'checkbox' : 'modal'
+ loginAgreementUpdatedAt.value = settings.login_agreement_updated_at || ''
+ loginAgreementRevision.value =
+ settings.login_agreement_revision ||
+ `${loginAgreementUpdatedAt.value}:${documents.map((doc) => `${doc.id}:${doc.title}`).join('|')}`
+
+ agreementAccepted.value = !loginAgreementEnabled.value || hasAcceptedLoginAgreement(loginAgreementRevision.value)
+ showAgreementModal.value =
+ loginAgreementEnabled.value && !agreementAccepted.value && loginAgreementMode.value !== 'checkbox'
+}
+
+function hasAcceptedLoginAgreement(revision: string): boolean {
+ if (!revision) {
+ return false
+ }
+ try {
+ const raw = localStorage.getItem(LOGIN_AGREEMENT_STORAGE_KEY)
+ if (!raw) {
+ return false
+ }
+ const parsed = JSON.parse(raw) as { revision?: string }
+ return parsed.revision === revision
+ } catch {
+ return false
+ }
+}
+
+function acceptLoginAgreement(): void {
+ if (loginAgreementRevision.value) {
+ localStorage.setItem(
+ LOGIN_AGREEMENT_STORAGE_KEY,
+ JSON.stringify({
+ revision: loginAgreementRevision.value,
+ accepted_at: new Date().toISOString()
+ })
+ )
+ }
+ agreementAccepted.value = true
+ showAgreementModal.value = false
+}
+
+function rejectLoginAgreement(): void {
+ localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
+ agreementAccepted.value = false
+ showAgreementModal.value = false
+ appStore.showWarning('未同意最新条款前,无法注册或使用快捷登录。')
+}
+
// ==================== Promo Code Validation ====================
function handlePromoCodeInput(): void {
@@ -656,6 +752,14 @@ function validateForm(): boolean {
let isValid = true
+ if (agreementGateActive.value) {
+ appStore.showWarning('请先阅读并同意最新条款后再注册。')
+ if (loginAgreementMode.value !== 'checkbox') {
+ showAgreementModal.value = true
+ }
+ return false
+ }
+
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
diff --git a/frontend/src/views/public/LegalDocumentView.vue b/frontend/src/views/public/LegalDocumentView.vue
new file mode 100644
index 00000000..4b1d9f8d
--- /dev/null
+++ b/frontend/src/views/public/LegalDocumentView.vue
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
+
+
+ {{ siteName }}
+
+
+
+ 登录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
文档不存在
+
+ 当前条款文档不存在或已被管理员移除。
+
+
+
+
+
+
+
+
+
+
+
+
+
登录条款
+
+ {{ currentDocument.title }}
+
+
+ 更新日期:{{ updatedAt }}
+
+
+
+
+
+
+
+ 暂无正文内容
+
+
+
+
+
+
+
+
+