fix(client): clear protected views on logout and show customer customs state

This commit is contained in:
2026-10-03 03:54:06 +08:00
parent b1f538b3ac
commit 5c53eba31b
7 changed files with 149 additions and 21 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ function mapError(error: unknown) {
let refreshPromise: Promise<string> | null = null
let refreshIdentity: string | null = null
let redirectingToLogin = false
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout', '/auth/impersonations/exchange']
function isPublicAuthRequest(url?: string) {
return publicAuthPaths.some((path) => url?.endsWith(path))
+17
View File
@@ -99,6 +99,23 @@ test('late refresh response cannot overwrite a session changed in another tab',
})
function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
test('logout after local clear sends only the original refresh token and never refreshes or clears a later login', async () => {
const sent = deferred(), release = deferred(), calls = []
const http = harness(async config => {
calls.push(config); sent.resolve(); await release.promise; throw unauthorized(config)
})
http.setSession('', '')
const api = compile('../modules/auth/api/auth.ts', { '@/api/http': http })
const pending = api.logoutMerchant('old-refresh')
const rejected = assert.rejects(pending, error => error.code === 'A0401')
await sent.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected
assert.equal(calls.length, 1)
assert.equal(calls[0].url, '/auth/logout')
assert.equal(calls[0].headers.Authorization, undefined)
assert.deepEqual(JSON.parse(calls[0].data), { refreshToken: 'old-refresh' })
assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' })
})
function unauthorized(config) {
return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } })
}
+49 -1
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
import * as vue from 'vue'
const layoutSource = readFileSync(new URL('./AppLayout.vue', import.meta.url), 'utf8')
const shellStyles = readFileSync(new URL('../styles/shell.css', import.meta.url), 'utf8')
@@ -108,7 +110,7 @@ test('source contract: locks body scrolling while open and restores it on close
})
test('source contract: handles confirmation cancellation but rethrows unexpected logout errors', () => {
assert.match(layoutSource, /async function handleLogout\(\) \{\s*try \{/)
assert.match(layoutSource, /async function handleLogout\(\) \{\s*const logoutToken = auth.token\s*try \{/)
assert.match(layoutSource, /\} catch \(error\) \{\s*if \(error === 'cancel' \|\| error === 'close'\) return\s*throw error\s*\}/)
})
@@ -148,3 +150,49 @@ test('source contract: routes denied pages to an explicit 403 state', () => {
assert.match(routerSource, /!auth\.hasPermission\(permission\)/)
assert.match(routerSource, /getFirstAccessiblePath\(auth\.permissionCodes\)/)
})
function logoutHarness(t) {
const script = layoutSource.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const ast = ts.createSourceFile('layout.ts', script, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const nodes = ast.statements.filter(node => (ts.isFunctionDeclaration(node) && node.name?.text === 'handleLogout') || (ts.isExpressionStatement(node) && node.getText(ast).startsWith('watch(') && node.getText(ast).includes('() => auth.isAuthenticated')))
const code = ts.transpileModule(nodes.map(node => node.getText(ast)).join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
let rejectLogout, confirm
const remote = new Promise((_, no) => { rejectLogout = no })
const confirmation = new Promise(yes => { confirm = yes })
const calls = [], warnings = []
const auth = vue.reactive({ token: 'A', isAuthenticated: true, logout: () => { calls.push('logout'); auth.token = ''; auth.isAuthenticated = false; return remote } })
const scope = vue.effectScope(); t.after(() => scope.stop())
const handler = scope.run(() => new Function('watch', 'auth', 'workspace', 'unsaved', 'router', 'ElMessageBox', 'ElMessage', code + ';return handleLogout')(vue.watch, auth, { clear: () => calls.push('workspace') }, { hasDirty: false, clear: () => calls.push('unsaved') }, { replace: path => { calls.push(path); return Promise.resolve() } }, { confirm: () => confirmation }, { warning: value => warnings.push(value) }))
return { auth, handler, confirm, rejectLogout, calls, warnings }
}
test('logout navigates before remote completion and late failure cannot affect a new login', async t => {
const h = logoutHarness(t)
const clicked = h.handler(); h.confirm(); await clicked; await vue.nextTick()
assert.deepEqual(h.calls, ['logout', 'workspace', 'unsaved', '/login'])
assert.equal(h.auth.isAuthenticated, false)
h.auth.token = 'B'; h.auth.isAuthenticated = true; await vue.nextTick()
h.rejectLogout(new Error('SESSION_CHANGED')); await Promise.resolve(); await vue.nextTick()
assert.equal(h.auth.token, 'B'); assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.calls.filter(item => item === '/login').length, 1)
assert.equal(h.warnings.length, 0)
})
test('cross-tab logout leaves protected route and stale confirmation never logs out a new identity', async t => {
const h = logoutHarness(t)
const clicked = h.handler()
h.auth.token = 'B'; h.confirm(); await clicked
assert.equal(h.calls.length, 0)
h.auth.token = ''; h.auth.isAuthenticated = false; await vue.nextTick()
assert.deepEqual(h.calls, ['workspace', 'unsaved', '/login'])
assert.match(layoutSource, /<div v-if="auth.isAuthenticated" class="client-shell"/)
})
test('remote logout failure reports incomplete server revocation without leaving protected content visible', async t => {
const h = logoutHarness(t)
const clicked = h.handler(); h.confirm(); await clicked; await vue.nextTick()
h.rejectLogout(new Error('offline')); await Promise.resolve()
assert.equal(h.auth.isAuthenticated, false)
assert.ok(h.calls.includes('/login'))
assert.deepEqual(h.warnings, ['已退出本页,服务器会话注销未完成'])
})
+10 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, LogOut, Menu, X } from '@lucide/vue'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
@@ -108,6 +108,7 @@ watch(
watch(
() => auth.permissionCodes.join('|'),
() => {
if (!auth.isAuthenticated) return
const result = workspace.reconcile(isAllowedTab)
if (result.closedActive) void router.replace(result.fallbackPath ?? getFirstAccessiblePath(auth.permissionCodes))
},
@@ -119,6 +120,7 @@ watch(
if (authenticated) return
workspace.clear()
unsaved.clear()
void router.replace('/login')
},
)
@@ -163,6 +165,7 @@ function handleBeforeUnload(event: BeforeUnloadEvent) {
}
async function handleLogout() {
const logoutToken = auth.token
try {
await ElMessageBox.confirm(unsaved.hasDirty
? '当前还有未保存的修改,退出后这些内容将丢失。确定继续吗?'
@@ -176,15 +179,16 @@ async function handleLogout() {
throw error
}
workspace.clear()
unsaved.clear()
await auth.logout()
await router.replace('/login')
if (auth.token !== logoutToken) return
void auth.logout().catch(() => {
if (!auth.isAuthenticated) ElMessage.warning('已退出本页,服务器会话注销未完成')
})
}
</script>
<template>
<div class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen, 'is-dashboard-route': route.path === '/' }">
<div v-if="auth.isAuthenticated" class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen, 'is-dashboard-route': route.path === '/' }">
<header class="top-header" :inert="mobileMenuOpen" :aria-hidden="mobileMenuOpen ? 'true' : undefined">
<button
ref="menuButtonRef"
+17 -1
View File
@@ -35,7 +35,7 @@ function harness(t, expired = false) {
class ApiError extends Error { constructor(status) { super('failed'); this.status = status } }
const module = compile('./auth.ts', {
vue, pinia, '@/api/session': session,
'@/modules/auth/api/auth': { logoutMerchant: async () => {} },
'@/modules/auth/api/auth': { logoutMerchant: async refresh => behavior.logout?.(refresh) },
'@/modules/auth/utils/jwt': compile('../utils/jwt.ts'),
'@/api/http': { ApiError, refreshAuthenticatedSession: async () => {
session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' })
@@ -136,3 +136,19 @@ test('stale unauthorized context response cannot clear a newer account', async t
assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.auth.sessionRestored, true)
})
test('store logout clears immediately and late revocation failure preserves a later cross-tab login', async t => {
const h = harness(t); await h.auth.restoreSession()
const remote = deferred(); let sentRefresh
h.behavior.logout = refresh => { sentRefresh = refresh; return remote.promise }
const pending = h.auth.logout()
const rejected = assert.rejects(pending, /offline/)
assert.equal(sentRefresh, 'refresh-a')
assert.equal(h.auth.isAuthenticated, false)
assert.equal(h.session.getAccessToken(), '')
h.rotate(token(2, 2, 2)); await flush()
remote.reject(new Error('offline')); await rejected
assert.equal(h.auth.user.id, '2')
assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.session.getRefreshToken(), 'refresh-b')
})
@@ -14,6 +14,9 @@ function evaluate(source, dependencies = {}) {
return exports
}
const eligibility = evaluate(readFileSync(new URL('../utils/customsEligibility.ts', import.meta.url), 'utf8'))
const customsApi = evaluate(readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8'), {
'@/api/http': {}, '@/modules/first-leg/api/firstLeg': {},
})
const order = (id, status = 'LOADED', taxMode = 'TAX_REFUND') => ({ id, status, taxMode, orderNo: `ORDER-${id}`, businessSummary: { inboundCode: `IN-${id}` } })
const detail = id => ({ status: 'COMPLETED', merchantStatus: 'CUSTOMS_COMPLETED', inbounds: [{ orderId: id, taxMode: 'TAX_REFUND' }] })
function harness(t, orders, getDetail = async id => detail(id)) {
@@ -38,9 +41,10 @@ function harness(t, orders, getDetail = async id => detail(id)) {
})
const source = readFileSync(new URL('./CustomsDocumentsView.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const scope = vue.effectScope()
const view = scope.run(() => evaluate(source + '\nexport { load, records, loading, errorMessage, openDetail, selectedOrderId, selectedDetail, dialogOpen, search, queryModel, filteredRecords }', {
const view = scope.run(() => evaluate(source + '\nexport { load, records, loading, errorMessage, openDetail, selectedOrderId, selectedDetail, dialogOpen, search, queryModel, filteredRecords, queryFields, statusLabel, statusType, updateDetail }', {
vue: { ...vue, onMounted() {} },
'../utils/customsDocumentsReader': reader,
'../api/customs': customsApi,
}))
t.after(() => scope.stop())
return { view, store, calls, pageCalls }
@@ -128,7 +132,7 @@ test('tax-refund documents remain accessible through delivered and completed ord
assert.equal(view.selectedOrderId.value, 'COMPLETED')
assert.equal(view.selectedDetail.value.status, 'COMPLETED')
assert.equal(view.dialogOpen.value, true)
view.queryModel.value.status = 'COMPLETED'; view.search()
view.queryModel.value.status = 'CUSTOMS_COMPLETED'; view.search()
assert.equal(view.filteredRecords.value.length, 3)
assert.equal(calls.length, 3, 'local filtering does not refetch documents')
})
@@ -175,3 +179,46 @@ test('repeated refresh during an active load does not duplicate requests', async
resolve(detail('one')); await first
assert.equal(view.loading.value, false)
})
test('mixed-container labels and filters use only the current customer status', async t => {
const states = [
['uploaded', 'WAITING_CUSTOMER_DOCUMENTS', 'DOCUMENT_UPLOADED', 1, '资料已上传', 'success'],
['waiting', 'WAITING_CUSTOMER_DOCUMENTS', 'WAITING_UPLOAD', 0, '待上传资料', 'warning'],
['declaring', 'DECLARING', 'DECLARING', 1, '报关中', 'primary'],
['completed', 'COMPLETED', 'CUSTOMS_COMPLETED', 1, '报关完成', 'success'],
]
const { view, calls } = harness(t, states.map(([id]) => order(id)), async id => {
const [, status, merchantStatus, coveredTaxRefundCount] = states.find(row => row[0] === id)
return { ...detail(id), status, merchantStatus, coveredTaxRefundCount, taxRefundCount: 1 }
})
await view.load()
for (const [id, , merchantStatus, , label, tone] of states) {
const row = view.records.value.find(item => item.order.id === id)
assert.equal(view.statusLabel(row.detail), label)
assert.equal(view.statusType(row.detail), tone)
view.queryModel.value.status = merchantStatus; view.search()
assert.deepEqual(view.filteredRecords.value.map(item => item.order.id), [id])
}
assert.deepEqual(view.queryFields.find(field => field.key === 'status').options,
states.map(([, , value, , label]) => ({ value, label })).sort((a, b) =>
['WAITING_UPLOAD', 'DOCUMENT_UPLOADED', 'DECLARING', 'CUSTOMS_COMPLETED'].indexOf(a.value)
- ['WAITING_UPLOAD', 'DOCUMENT_UPLOADED', 'DECLARING', 'CUSTOMS_COMPLETED'].indexOf(b.value)))
view.queryModel.value.status = 'WAITING_CUSTOMER_DOCUMENTS'; view.search()
assert.equal(view.filteredRecords.value.length, 0, 'container statuses are not customer filter values')
view.queryModel.value.status = ''; view.search()
assert.equal(view.filteredRecords.value.length, 4)
assert.equal(calls.length, 4, 'filtering does not requery or infer status from coverage')
})
test('dialog update refreshes customer status and active filter without inferring from container status', async t => {
const { view } = harness(t, [order('one')], async id => ({ ...detail(id), status: 'WAITING_CUSTOMER_DOCUMENTS', merchantStatus: 'WAITING_UPLOAD' }))
await view.load(); view.openDetail(view.records.value[0])
view.queryModel.value.status = 'WAITING_UPLOAD'; view.search()
view.updateDetail({ ...detail('one'), status: 'WAITING_CUSTOMER_DOCUMENTS', merchantStatus: 'DOCUMENT_UPLOADED', coveredTaxRefundCount: 1, taxRefundCount: 1 })
assert.equal(view.filteredRecords.value.length, 0)
view.queryModel.value.status = 'DOCUMENT_UPLOADED'; view.search()
assert.equal(view.filteredRecords.value.length, 1)
assert.equal(view.statusLabel(view.filteredRecords.value[0].detail), '资料已上传')
assert.equal(view.statusLabel({ status: 'COMPLETED', coveredTaxRefundCount: 1, taxRefundCount: 1 }), '—')
assert.equal(view.statusType({ status: 'COMPLETED' }), 'info')
})
@@ -4,7 +4,7 @@ import { RefreshCcw } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import type { TableAction } from '@/components/common/tableActions'
import type { MerchantCustomsContainer } from '../api/customs'
import { MERCHANT_CUSTOMS_STATUS_LABELS, type MerchantCustomsContainer } from '../api/customs'
import { readCustomsDocuments, type CustomsDocumentRow } from '../utils/customsDocumentsReader'
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
@@ -18,12 +18,7 @@ const queryFields: QueryField[] = [
{ key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' },
{ key: 'inboundCode', label: '入仓码', type: 'input', placeholder: '请输入入仓码' },
{ key: 'containerNo', label: '柜号', type: 'input', placeholder: '请输入柜号' },
{ key: 'status', label: '状态', type: 'select', placeholder: '全部', options: [
{ label: '待上传资料', value: 'WAITING_CUSTOMER_DOCUMENTS' },
{ label: '待报关', value: 'READY_TO_START' },
{ label: '报关中', value: 'DECLARING' },
{ label: '报关完成', value: 'COMPLETED' },
] },
{ key: 'status', label: '状态', type: 'select', placeholder: '全部', options: Object.entries(MERCHANT_CUSTOMS_STATUS_LABELS).map(([value, label]) => ({ label, value })) },
]
const columns: DataTableColumn[] = [
{ label: '操作', slotName: 'actions', width: 120, fixed: 'left' },
@@ -46,7 +41,7 @@ const filteredRecords = computed(() => {
matches(order.orderNo, 'orderNo')
&& matches(order.businessSummary.inboundCode, 'inboundCode')
&& (matches(detail.containerNo, 'containerNo') || matches(detail.cabinetNo, 'containerNo'))
&& (!query.status || detail.status === query.status),
&& (!query.status || detail.merchantStatus === query.status),
)
})
const pagedRecords = computed(() => filteredRecords.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
@@ -65,10 +60,11 @@ function rowActions(row: object): TableAction[] {
}
function statusLabel(detail: MerchantCustomsContainer) {
return ({ WAITING_CUSTOMER_DOCUMENTS: '待上传资料', READY_TO_START: '待报关', DECLARING: '报关中', COMPLETED: '报关完成' } as Record<string, string>)[detail.status] ?? detail.status
return detail.merchantStatus ? MERCHANT_CUSTOMS_STATUS_LABELS[detail.merchantStatus] ?? '—' : '—'
}
function statusType(detail: MerchantCustomsContainer) {
return detail.status === 'COMPLETED' ? 'success' : detail.status === 'DECLARING' ? 'primary' : 'warning'
return detail.merchantStatus === 'CUSTOMS_COMPLETED' || detail.merchantStatus === 'DOCUMENT_UPLOADED'
? 'success' : detail.merchantStatus === 'DECLARING' ? 'primary' : detail.merchantStatus === 'WAITING_UPLOAD' ? 'warning' : 'info'
}
async function load() {
if (loading.value) return