fix(client): clear protected views on logout and show customer customs state
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user