mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 00:07:33 +00:00
Add admin compliance acknowledgement gate
This commit is contained in:
+19
-1
@@ -3,9 +3,10 @@ import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Toast from '@/components/common/Toast.vue'
|
||||
import NavigationProgress from '@/components/common/NavigationProgress.vue'
|
||||
import AdminComplianceDialog from '@/components/admin/AdminComplianceDialog.vue'
|
||||
import { resolveDocumentTitle } from '@/router/title'
|
||||
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
|
||||
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore } from '@/stores'
|
||||
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore, useAdminComplianceStore } from '@/stores'
|
||||
import { getSetupStatus } from '@/api/setup'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -14,6 +15,7 @@ const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const subscriptionStore = useSubscriptionStore()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
const adminComplianceStore = useAdminComplianceStore()
|
||||
|
||||
/**
|
||||
* Update favicon dynamically
|
||||
@@ -49,10 +51,21 @@ function onVisibilityChange() {
|
||||
}
|
||||
}
|
||||
|
||||
function onAdminComplianceRequired(event: Event) {
|
||||
const detail = (event as CustomEvent<Record<string, string>>).detail || {}
|
||||
adminComplianceStore.requireAcknowledgement(detail)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(isAuthenticated, oldValue) => {
|
||||
if (isAuthenticated) {
|
||||
if (authStore.isAdmin) {
|
||||
adminComplianceStore.fetchStatus().catch((error) => {
|
||||
console.error('Failed to fetch admin compliance status:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// User logged in: preload subscriptions and start polling
|
||||
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
|
||||
console.error('Failed to preload subscriptions:', error)
|
||||
@@ -74,6 +87,7 @@ watch(
|
||||
// User logged out: clear data and stop polling
|
||||
subscriptionStore.clear()
|
||||
announcementStore.reset()
|
||||
adminComplianceStore.reset()
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
},
|
||||
@@ -89,9 +103,12 @@ router.afterEach(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('admin-compliance-required', onAdminComplianceRequired)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('admin-compliance-required', onAdminComplianceRequired)
|
||||
|
||||
// Check if setup is needed
|
||||
try {
|
||||
const status = await getSetupStatus()
|
||||
@@ -116,4 +133,5 @@ onMounted(async () => {
|
||||
<RouterView />
|
||||
<Toast />
|
||||
<AnnouncementPopup />
|
||||
<AdminComplianceDialog />
|
||||
</template>
|
||||
|
||||
@@ -143,6 +143,53 @@ describe('API Client', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('部署与运营合规未确认时广播事件且保留登录态', async () => {
|
||||
localStorage.setItem('auth_token', 'admin-token')
|
||||
const listener = vi.fn()
|
||||
window.addEventListener('admin-compliance-required', listener)
|
||||
|
||||
const adapter = vi.fn().mockRejectedValue({
|
||||
response: {
|
||||
status: 423,
|
||||
data: {
|
||||
code: 'ADMIN_COMPLIANCE_ACK_REQUIRED',
|
||||
message: 'administrator compliance acknowledgement is required',
|
||||
metadata: {
|
||||
version: 'v2026.06.10',
|
||||
document_path_zh: 'docs/legal/admin-compliance.zh.md',
|
||||
document_path_en: 'docs/legal/admin-compliance.en.md',
|
||||
},
|
||||
},
|
||||
},
|
||||
config: {
|
||||
url: '/admin/users',
|
||||
headers: { Authorization: 'Bearer admin-token' },
|
||||
},
|
||||
code: 'ERR_BAD_REQUEST',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await expect(apiClient.get('/admin/users')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 423,
|
||||
code: 'ADMIN_COMPLIANCE_ACK_REQUIRED',
|
||||
metadata: expect.objectContaining({
|
||||
version: 'v2026.06.10',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual(
|
||||
expect.objectContaining({
|
||||
version: 'v2026.06.10',
|
||||
})
|
||||
)
|
||||
expect(localStorage.getItem('auth_token')).toBe('admin-token')
|
||||
|
||||
window.removeEventListener('admin-compliance-required', listener)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 401 Token 刷新 ---
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { apiClient } from '@/api/client'
|
||||
|
||||
export interface AdminComplianceAcknowledgement {
|
||||
version: string
|
||||
document_zh: string
|
||||
document_en: string
|
||||
admin_user_id: number
|
||||
ip_address?: string
|
||||
user_agent?: string
|
||||
accepted_at: string
|
||||
}
|
||||
|
||||
export interface AdminComplianceStatus {
|
||||
required: boolean
|
||||
version: string
|
||||
document_path_zh: string
|
||||
document_path_en: string
|
||||
document_url_zh: string
|
||||
document_url_en: string
|
||||
ack_phrase_zh: string
|
||||
ack_phrase_en: string
|
||||
acknowledgement?: AdminComplianceAcknowledgement
|
||||
}
|
||||
|
||||
export interface AcceptAdminComplianceRequest {
|
||||
phrase: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export const adminComplianceAPI = {
|
||||
async getStatus(): Promise<AdminComplianceStatus> {
|
||||
const { data } = await apiClient.get<AdminComplianceStatus>('/admin/compliance')
|
||||
return data
|
||||
},
|
||||
|
||||
async accept(payload: AcceptAdminComplianceRequest): Promise<AdminComplianceStatus> {
|
||||
const { data } = await apiClient.post<AdminComplianceStatus>('/admin/compliance/accept', payload)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export default adminComplianceAPI
|
||||
@@ -31,6 +31,7 @@ import channelMonitorTemplateAPI from './channelMonitorTemplate'
|
||||
import adminPaymentAPI from './payment'
|
||||
import affiliatesAPI from './affiliates'
|
||||
import riskControlAPI from './riskControl'
|
||||
import adminComplianceAPI from './compliance'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -63,7 +64,8 @@ export const adminAPI = {
|
||||
channelMonitorTemplate: channelMonitorTemplateAPI,
|
||||
payment: adminPaymentAPI,
|
||||
affiliates: affiliatesAPI,
|
||||
riskControl: riskControlAPI
|
||||
riskControl: riskControlAPI,
|
||||
compliance: adminComplianceAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -94,7 +96,8 @@ export {
|
||||
channelMonitorTemplateAPI,
|
||||
adminPaymentAPI,
|
||||
affiliatesAPI,
|
||||
riskControlAPI
|
||||
riskControlAPI,
|
||||
adminComplianceAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
|
||||
@@ -148,6 +148,23 @@ apiClient.interceptors.response.use(
|
||||
})
|
||||
}
|
||||
|
||||
if (status === 423 && apiData.code === 'ADMIN_COMPLIANCE_ACK_REQUIRED') {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('admin-compliance-required', {
|
||||
detail: apiData.metadata || {}
|
||||
}))
|
||||
} catch {
|
||||
// ignore event failures
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
status,
|
||||
code: apiData.code,
|
||||
message: apiData.message || error.message,
|
||||
metadata: apiData.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// 401: Try to refresh the token if we have a refresh token
|
||||
// This handles TOKEN_EXPIRED, INVALID_TOKEN, TOKEN_REVOKED, etc.
|
||||
if (status === 401 && !originalRequest._retry) {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="visible"
|
||||
:title="t('adminCompliance.title')"
|
||||
width="wide"
|
||||
:close-on-escape="false"
|
||||
:close-on-click-outside="false"
|
||||
:show-close-button="false"
|
||||
:z-index="80"
|
||||
@close="noop"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
<div class="flex gap-3">
|
||||
<Icon name="exclamationTriangle" size="md" class="mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold">{{ t('adminCompliance.blockingNotice') }}</p>
|
||||
<p class="leading-6">{{ t('adminCompliance.riskNotice') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_240px]">
|
||||
<section class="min-h-[320px] max-h-[46vh] overflow-y-auto rounded-lg border border-gray-200 bg-white p-5 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="legal-document-content" v-html="renderedDocument"></div>
|
||||
</section>
|
||||
|
||||
<aside class="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm dark:border-dark-700 dark:bg-dark-900/60">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-dark-400">
|
||||
{{ t('adminCompliance.version') }}
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-gray-900 dark:text-white">
|
||||
{{ complianceStore.status?.version || 'v2026.06.10' }}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
:href="documentUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 text-primary-600 underline underline-offset-4 hover:text-primary-700 dark:text-primary-300 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" />
|
||||
{{ t('adminCompliance.openDocument') }}
|
||||
</a>
|
||||
<p class="leading-6 text-gray-600 dark:text-dark-300">
|
||||
{{ t('adminCompliance.documentSource') }}
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label for="admin-compliance-phrase" class="block text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('adminCompliance.inputLabel') }}
|
||||
</label>
|
||||
<div class="rounded-lg bg-gray-100 px-3 py-2 font-mono text-sm text-gray-900 dark:bg-dark-800 dark:text-dark-100">
|
||||
{{ expectedPhrase }}
|
||||
</div>
|
||||
<Input
|
||||
id="admin-compliance-phrase"
|
||||
v-model="typedPhrase"
|
||||
:placeholder="t('adminCompliance.inputPlaceholder')"
|
||||
autocomplete="off"
|
||||
:disabled="complianceStore.submitting"
|
||||
:error="inputError"
|
||||
@enter="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs leading-5 text-gray-500 dark:text-dark-400">
|
||||
{{ t('adminCompliance.legalNote') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="complianceStore.submitting"
|
||||
@click="logout"
|
||||
>
|
||||
{{ t('adminCompliance.logout') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!canSubmit || complianceStore.submitting"
|
||||
@click="submit"
|
||||
>
|
||||
<span v-if="complianceStore.submitting">{{ t('common.submitting') }}</span>
|
||||
<span v-else>{{ t('adminCompliance.accept') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Input from '@/components/common/Input.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAdminComplianceStore, useAppStore, useAuthStore } from '@/stores'
|
||||
import { getLocale } from '@/i18n'
|
||||
import zhDocument from '../../../../docs/legal/admin-compliance.zh.md?raw'
|
||||
import enDocument from '../../../../docs/legal/admin-compliance.en.md?raw'
|
||||
|
||||
const { t } = useI18n()
|
||||
const complianceStore = useAdminComplianceStore()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
const typedPhrase = ref('')
|
||||
const attemptedSubmit = ref(false)
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
const visible = computed(() => authStore.isAuthenticated && authStore.isAdmin && complianceStore.shouldShow)
|
||||
const expectedPhrase = computed(() => complianceStore.expectedPhrase)
|
||||
const canSubmit = computed(() => typedPhrase.value.trim() === expectedPhrase.value)
|
||||
const currentDocument = computed(() => getLocale() === 'zh' ? zhDocument : enDocument)
|
||||
const documentUrl = computed(() => {
|
||||
if (getLocale() === 'zh') {
|
||||
return complianceStore.status?.document_url_zh || 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.zh.md'
|
||||
}
|
||||
return complianceStore.status?.document_url_en || 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.en.md'
|
||||
})
|
||||
const inputError = computed(() => {
|
||||
if (!attemptedSubmit.value || canSubmit.value) {
|
||||
return ''
|
||||
}
|
||||
return t('adminCompliance.inputMismatch')
|
||||
})
|
||||
const renderedDocument = computed(() => {
|
||||
const html = marked.parse(currentDocument.value) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
})
|
||||
|
||||
watch(expectedPhrase, () => {
|
||||
typedPhrase.value = ''
|
||||
attemptedSubmit.value = false
|
||||
})
|
||||
|
||||
watch(visible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
typedPhrase.value = ''
|
||||
attemptedSubmit.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function noop(): void {
|
||||
// 强制确认弹窗不允许通过关闭按钮绕过。
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
attemptedSubmit.value = true
|
||||
if (!canSubmit.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await complianceStore.accept(typedPhrase.value.trim())
|
||||
if (!status.required) {
|
||||
appStore.showSuccess(t('adminCompliance.accepted'))
|
||||
typedPhrase.value = ''
|
||||
attemptedSubmit.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
const message = (error as { message?: string })?.message || t('adminCompliance.acceptFailed')
|
||||
appStore.showError(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await authStore.logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.legal-document-content {
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.legal-document-content :deep(h1) {
|
||||
@apply mb-4 text-2xl font-bold text-gray-950 dark:text-white;
|
||||
}
|
||||
|
||||
.legal-document-content :deep(h2) {
|
||||
@apply mb-3 mt-6 text-xl font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.legal-document-content :deep(p) {
|
||||
@apply mb-4 text-sm text-gray-700 dark:text-dark-200;
|
||||
}
|
||||
|
||||
.legal-document-content :deep(ul),
|
||||
.legal-document-content :deep(ol) {
|
||||
@apply mb-4 pl-6 text-sm text-gray-700 dark:text-dark-200;
|
||||
}
|
||||
|
||||
.legal-document-content :deep(ul) {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.legal-document-content :deep(ol) {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.legal-document-content :deep(li) {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
.legal-document-content :deep(strong) {
|
||||
@apply font-semibold text-gray-950 dark:text-white;
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="showCloseButton"
|
||||
@click="emit('close')"
|
||||
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
|
||||
aria-label="Close modal"
|
||||
@@ -61,6 +62,7 @@ interface Props {
|
||||
width?: DialogWidth
|
||||
closeOnEscape?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
showCloseButton?: boolean
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
width: 'normal',
|
||||
closeOnEscape: true,
|
||||
closeOnClickOutside: false,
|
||||
showCloseButton: true,
|
||||
zIndex: 50
|
||||
})
|
||||
|
||||
|
||||
@@ -344,6 +344,34 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
adminCompliance: {
|
||||
title: 'Deployment and Operation Compliance Acknowledgment',
|
||||
blockingNotice: 'Deployment and operation compliance acknowledgment is required before continuing to use the console.',
|
||||
riskNotice: 'This acknowledgment provides clear, conspicuous, and reproducible notice of compliance obligations and operation risks for self-hosted instances.',
|
||||
version: 'Document Version',
|
||||
openDocument: 'Open the GitHub document',
|
||||
documentSource: 'The agreement text comes from Markdown files in this project repository. When the agreement content changes, the document version must be incremented; acknowledgments of older versions become invalid and console users must acknowledge again.',
|
||||
inputLabel: 'Type the following confirmation phrase exactly',
|
||||
inputPlaceholder: 'Type the confirmation phrase to continue',
|
||||
inputMismatch: 'The confirmation phrase does not match. Type the displayed text exactly.',
|
||||
legalNote: 'This acknowledgment defines the no-affiliation relationship and responsibility boundary between self-hosted instances and the open-source project, copyright holders, contributors, and maintainers. The party that deploys, operates, or controls the relevant instance remains independently responsible for its applicable obligations.',
|
||||
logout: 'Log out',
|
||||
accept: 'Acknowledge and Continue',
|
||||
accepted: 'Compliance acknowledgment recorded',
|
||||
acceptFailed: 'Failed to submit acknowledgment'
|
||||
},
|
||||
|
||||
legal: {
|
||||
loadFailed: 'Failed to load document',
|
||||
retryLater: 'Refresh the page and try again later.',
|
||||
notFound: 'Document not found',
|
||||
notFoundDescription: 'This legal document does not exist or has been removed by an administrator.',
|
||||
updatedAt: 'Updated: {date}',
|
||||
empty: 'No content',
|
||||
loginAgreement: 'Login Agreement',
|
||||
adminCompliance: 'Deployment and Operation Compliance Commitment'
|
||||
},
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
|
||||
@@ -344,6 +344,34 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
adminCompliance: {
|
||||
title: '部署与运营合规确认',
|
||||
blockingNotice: '继续使用控制台前,须完成部署与运营合规确认。',
|
||||
riskNotice: '本确认用于以清晰、显著、可留痕的方式提示自部署实例的合规义务与运营风险。',
|
||||
version: '协议版本',
|
||||
openDocument: '在 GitHub 查看协议文件',
|
||||
documentSource: '协议正文来自本项目仓库中的 Markdown 文件。修改协议内容时必须同步递增协议版本;已确认的旧版本将失效,控制台使用者须重新确认。',
|
||||
inputLabel: '请逐字输入以下确认短语',
|
||||
inputPlaceholder: '输入确认短语以继续',
|
||||
inputMismatch: '确认短语不匹配,请逐字输入提示内容。',
|
||||
legalNote: '本确认用于明确自部署实例与开源项目、著作权人、贡献者及维护者之间的非关联关系和责任边界;部署、运营或控制相关实例的主体应独立承担其适用义务。',
|
||||
logout: '退出登录',
|
||||
accept: '确认并继续',
|
||||
accepted: '合规确认已记录',
|
||||
acceptFailed: '提交确认失败'
|
||||
},
|
||||
|
||||
legal: {
|
||||
loadFailed: '文档加载失败',
|
||||
retryLater: '请稍后刷新页面重试。',
|
||||
notFound: '文档不存在',
|
||||
notFoundDescription: '当前条款文档不存在或已被管理员移除。',
|
||||
updatedAt: '更新日期:{date}',
|
||||
empty: '暂无正文内容',
|
||||
loginAgreement: '登录条款',
|
||||
adminCompliance: '部署与运营合规承诺'
|
||||
},
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: '仪表盘',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAdminSettingsStore } from '@/stores/adminSettings'
|
||||
import { useAdminComplianceStore } from '@/stores/adminCompliance'
|
||||
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
import { getSetupStatus } from '@/api/setup'
|
||||
@@ -45,7 +46,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Login',
|
||||
titleKey: 'common.login'
|
||||
titleKey: 'home.login'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -807,6 +808,20 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresAdmin && authStore.isAdmin) {
|
||||
const adminComplianceStore = useAdminComplianceStore()
|
||||
if (!adminComplianceStore.initialized) {
|
||||
try {
|
||||
await adminComplianceStore.fetchStatus()
|
||||
} catch (error) {
|
||||
const err = error as { status?: number; code?: string; metadata?: Record<string, string> }
|
||||
if (err.status === 423 && err.code === 'ADMIN_COMPLIANCE_ACK_REQUIRED') {
|
||||
adminComplianceStore.requireAcknowledgement(err.metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check payment requirement (internal payment system only)
|
||||
if (to.meta.requiresPayment) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import adminComplianceAPI, { type AdminComplianceStatus } from '@/api/admin/compliance'
|
||||
import { getLocale } from '@/i18n'
|
||||
|
||||
const FALLBACK_ZH_PHRASE = '我已阅读、理解并同意 Sub2API 部署与运营合规承诺'
|
||||
const FALLBACK_EN_PHRASE = 'I have read, understood, and agree to the Sub2API Deployment and Operation Compliance Commitment'
|
||||
|
||||
export const useAdminComplianceStore = defineStore('adminCompliance', () => {
|
||||
const status = ref<AdminComplianceStatus | null>(null)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const initialized = ref(false)
|
||||
const forceVisible = ref(false)
|
||||
|
||||
const required = computed(() => status.value?.required === true)
|
||||
const shouldShow = computed(() => required.value || forceVisible.value)
|
||||
const currentLocale = computed(() => getLocale())
|
||||
const expectedPhrase = computed(() => {
|
||||
if (currentLocale.value === 'zh') {
|
||||
return status.value?.ack_phrase_zh || FALLBACK_ZH_PHRASE
|
||||
}
|
||||
return status.value?.ack_phrase_en || FALLBACK_EN_PHRASE
|
||||
})
|
||||
|
||||
async function fetchStatus(): Promise<AdminComplianceStatus> {
|
||||
loading.value = true
|
||||
try {
|
||||
const nextStatus = await adminComplianceAPI.getStatus()
|
||||
status.value = nextStatus
|
||||
initialized.value = true
|
||||
forceVisible.value = nextStatus.required
|
||||
return nextStatus
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function accept(phrase: string): Promise<AdminComplianceStatus> {
|
||||
submitting.value = true
|
||||
try {
|
||||
const nextStatus = await adminComplianceAPI.accept({
|
||||
phrase,
|
||||
language: currentLocale.value
|
||||
})
|
||||
status.value = nextStatus
|
||||
forceVisible.value = nextStatus.required
|
||||
return nextStatus
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function requireAcknowledgement(partialStatus?: Partial<AdminComplianceStatus>): void {
|
||||
status.value = {
|
||||
required: true,
|
||||
version: partialStatus?.version || status.value?.version || 'v2026.06.10',
|
||||
document_path_zh: partialStatus?.document_path_zh || status.value?.document_path_zh || 'docs/legal/admin-compliance.zh.md',
|
||||
document_path_en: partialStatus?.document_path_en || status.value?.document_path_en || 'docs/legal/admin-compliance.en.md',
|
||||
document_url_zh: partialStatus?.document_url_zh || status.value?.document_url_zh || 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.zh.md',
|
||||
document_url_en: partialStatus?.document_url_en || status.value?.document_url_en || 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/legal/admin-compliance.en.md',
|
||||
ack_phrase_zh: partialStatus?.ack_phrase_zh || status.value?.ack_phrase_zh || FALLBACK_ZH_PHRASE,
|
||||
ack_phrase_en: partialStatus?.ack_phrase_en || status.value?.ack_phrase_en || FALLBACK_EN_PHRASE,
|
||||
acknowledgement: status.value?.acknowledgement
|
||||
}
|
||||
initialized.value = true
|
||||
forceVisible.value = true
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
status.value = null
|
||||
loading.value = false
|
||||
submitting.value = false
|
||||
initialized.value = false
|
||||
forceVisible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
loading,
|
||||
submitting,
|
||||
initialized,
|
||||
required,
|
||||
shouldShow,
|
||||
expectedPhrase,
|
||||
fetchStatus,
|
||||
accept,
|
||||
requireAcknowledgement,
|
||||
reset
|
||||
}
|
||||
})
|
||||
@@ -10,6 +10,7 @@ export { useSubscriptionStore } from './subscriptions'
|
||||
export { useOnboardingStore } from './onboarding'
|
||||
export { useAnnouncementStore } from './announcements'
|
||||
export { usePaymentStore } from './payment'
|
||||
export { useAdminComplianceStore } from './adminCompliance'
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
to="/login"
|
||||
class="inline-flex flex-shrink-0 items-center justify-center rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-primary-600/20 transition hover:bg-primary-700"
|
||||
>
|
||||
登录
|
||||
{{ t('home.login') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
@@ -28,8 +28,8 @@
|
||||
v-else-if="loadError"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-6 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200"
|
||||
>
|
||||
<h1 class="text-lg font-semibold">文档加载失败</h1>
|
||||
<p class="mt-2 text-sm">请稍后刷新页面重试。</p>
|
||||
<h1 class="text-lg font-semibold">{{ t('legal.loadFailed') }}</h1>
|
||||
<p class="mt-2 text-sm">{{ t('legal.retryLater') }}</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -41,9 +41,9 @@
|
||||
<Icon name="document" size="sm" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">文档不存在</h1>
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('legal.notFound') }}</h1>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-dark-300">
|
||||
当前条款文档不存在或已被管理员移除。
|
||||
{{ t('legal.notFoundDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,12 +56,12 @@
|
||||
<Icon :name="documentIcon" size="md" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-primary-700 dark:text-primary-300">登录条款</p>
|
||||
<p class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ documentTypeLabel }}</p>
|
||||
<h1 class="mt-2 break-words text-2xl font-bold tracking-normal text-gray-950 dark:text-white sm:text-3xl">
|
||||
{{ currentDocument.title }}
|
||||
</h1>
|
||||
<p v-if="updatedAt" class="mt-3 text-sm text-gray-500 dark:text-dark-400">
|
||||
更新日期:{{ updatedAt }}
|
||||
{{ t('legal.updatedAt', { date: updatedAt }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-14 text-center text-sm text-gray-500 dark:border-dark-700 dark:bg-dark-900 dark:text-dark-400"
|
||||
>
|
||||
暂无正文内容
|
||||
{{ t('legal.empty') }}
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
@@ -88,14 +88,19 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { getLocale } from '@/i18n'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import type { LoginAgreementDocument, PublicSettings } from '@/types'
|
||||
import zhAdminCompliance from '../../../../docs/legal/admin-compliance.zh.md?raw'
|
||||
import enAdminCompliance from '../../../../docs/legal/admin-compliance.en.md?raw'
|
||||
|
||||
type LegalDocumentIcon = 'document' | 'shield' | 'globe' | 'cog'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const settings = ref<PublicSettings | null>(null)
|
||||
const loading = ref(true)
|
||||
const loadError = ref(false)
|
||||
@@ -106,15 +111,28 @@ marked.setOptions({
|
||||
})
|
||||
|
||||
const documentId = computed(() => String(route.params.documentId || ''))
|
||||
const isAdminComplianceDocument = computed(() => documentId.value === 'admin-compliance')
|
||||
const documents = computed(() => settings.value?.login_agreement_documents ?? [])
|
||||
const siteName = computed(() => settings.value?.site_name || 'Sub2API')
|
||||
const siteLogo = computed(() => sanitizeUrl(settings.value?.site_logo || '', {
|
||||
allowRelative: true,
|
||||
allowDataUrl: true,
|
||||
}))
|
||||
const updatedAt = computed(() => settings.value?.login_agreement_updated_at || '')
|
||||
const updatedAt = computed(() =>
|
||||
isAdminComplianceDocument.value ? '' : settings.value?.login_agreement_updated_at || ''
|
||||
)
|
||||
const documentTypeLabel = computed(() =>
|
||||
isAdminComplianceDocument.value ? t('legal.adminCompliance') : t('legal.loginAgreement')
|
||||
)
|
||||
|
||||
const currentDocument = computed<LoginAgreementDocument | null>(() => {
|
||||
if (isAdminComplianceDocument.value) {
|
||||
return {
|
||||
id: 'admin-compliance',
|
||||
title: t('adminCompliance.title'),
|
||||
content_md: getLocale() === 'zh' ? zhAdminCompliance : enAdminCompliance
|
||||
}
|
||||
}
|
||||
const id = documentId.value
|
||||
if (!id) {
|
||||
return null
|
||||
|
||||
Vendored
+5
@@ -14,3 +14,8 @@ declare module '*.vue' {
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module '*.md?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user