fix(client): clear protected views on logout and show customer customs state
This commit is contained in:
+1
-1
@@ -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))
|
||||
|
||||
@@ -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' } })
|
||||
}
|
||||
|
||||
@@ -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, ['已退出本页,服务器会话注销未完成'])
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user