fix(admin): complete IAM identity status columns

This commit is contained in:
2026-10-30 12:00:16 +08:00
parent aab0babd7e
commit a676d0936b
2 changed files with 248 additions and 5 deletions
@@ -0,0 +1,156 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const ts = require('typescript')
const adminRoot = process.env.IDENTITY_UI_ROOT || path.resolve(import.meta.dirname, '..')
const file = path.join(adminRoot, 'src/views/permission/identity/index.vue')
const source = readFileSync(file, 'utf8')
const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)?.[1] || ''
function load(names, scope = {}) {
const ast = ts.createSourceFile(
'identity.ts',
script,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS
)
const found = ast.statements.filter(
(node) => ts.isFunctionDeclaration(node) && names.includes(node.name?.text)
)
assert.equal(found.length, names.length, 'all requested functions exist in the actual SFC')
const output = ts.transpileModule(found.map((node) => node.getText(ast)).join('\n'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
return new Function(...Object.keys(scope), output + '\nreturn {' + names.join(',') + '}')(
...Object.values(scope)
)
}
test('adds exactly the requested department/access/check-time columns and retains native pagination', () => {
for (const label of ['部门', '应用访问状态', '最近校验时间'])
assert.ok(source.includes(`label="${label}"`), label)
assert.match(source, /const query = reactive\(\{ q: '', state: '', page: 1, pageSize: 20 \}\)/)
assert.match(source, /query\.page = 1/)
assert.match(source, /v-model:current-page="query.page"/)
const actions = source.split('<el-table-column label="操作"')[1].split('</el-table>')[0]
assert.equal((actions.match(/type="primary"/g) || []).length, 2)
assert.match(actions, /<el-dropdown/)
})
test('department strings distinguish unassigned from unavailable data', () => {
const { departmentText } = load(['departmentText'])
assert.equal(departmentText({ dept_names: ['销售一部', '运营部'] }), '销售一部、运营部')
assert.equal(departmentText({ dept_names: [] }), '未分配')
assert.equal(departmentText({}), '未知')
assert.equal(departmentText({ dept_names: null }), '未知')
assert.equal(departmentText({ dept_names: [42] }), '未知')
})
test('authorization is never inferred from verified binding or enabled local account', () => {
const { applicationAccess } = load(['applicationAccess'])
const row = { disable: 0, iam: { state: 'verified' } }
assert.equal(applicationAccess(row).text, '未知')
assert.equal(
applicationAccess({ ...row, iam: { state: 'verified', access: { allowed: true } } }).text,
'已授权'
)
assert.equal(
applicationAccess({ ...row, iam: { state: 'verified', access: { allowed: false } } }).text,
'未授权'
)
assert.equal(
applicationAccess({ ...row, iam: { state: 'verified', access: { allowed: 'true' } } }).text,
'未知'
)
assert.equal(
applicationAccess({ ...row, iam: { state: 'unknown', access: { allowed: true } } }).text,
'未知'
)
})
test('unlinked, revoked, service failure and unknown remain distinct without stale allow=true', () => {
const { applicationAccess } = load(['applicationAccess'])
assert.equal(applicationAccess({ iam: { state: 'unlinked' } }).text, '未关联')
assert.equal(
applicationAccess({ iam: { state: 'revoked', access: { allowed: false } } }).text,
'未关联'
)
assert.equal(
applicationAccess({
iam: { state: 'unknown', access: { allowed: null, reason: 'SERVICE_UNAVAILABLE' } }
}).text,
'服务异常'
)
assert.equal(
applicationAccess({
iam: { state: 'verified', access: { allowed: true, reason: 'SERVICE_UNAVAILABLE' } }
}).text,
'服务异常'
)
assert.equal(
applicationAccess({
iam: { state: 'verified', access: { allowed: false, reason: 'NOT_LINKED' } }
}).text,
'未关联'
)
assert.equal(applicationAccess({ iam: { state: 'unknown' } }).text, '未知')
for (const [reason, expected] of Object.entries({
NOT_AUTHORIZED: '未授权',
ACCOUNT_DISABLED: 'IAM账号停用',
BINDING_CONFLICT: '关联冲突',
ACCOUNT_MISMATCH: '身份不匹配'
})) {
assert.equal(
applicationAccess({ iam: { state: 'unknown', access: { allowed: null, reason } } })
.text,
expected
)
assert.equal(
applicationAccess({ iam: { state: 'verified', access: { allowed: true, reason } } })
.text,
expected
)
}
})
test('Unix seconds display Beijing time and absent/invalid timestamps never become a fake recent check', () => {
const { checkedAtText } = load(['checkedAtText'])
assert.equal(checkedAtText(1700000000), '2023/11/15 06:13:20')
assert.equal(checkedAtText('1700000000'), checkedAtText(1700000000))
for (const value of [null, undefined, 0, '', '0']) assert.equal(checkedAtText(value), '未校验')
for (const value of [true, 'invalid', -1, Infinity, 1700000000000])
assert.equal(checkedAtText(value), '未知')
})
test('disabled writes are guarded even if an event handler is invoked directly', async () => {
let notices = 0,
posts = 0
const drawerError = { value: '' }
const scope = {
ElMessage: { warning: () => notices++ },
selected: { value: { disable: 1 } },
drawerError,
iamPost: async () => {
posts++
}
}
const { isLocalDisabled, open, submit } = load(['isLocalDisabled', 'open', 'submit'], scope)
for (const disable of [1, true, '1']) assert.equal(isLocalDisabled({ disable }), true)
for (const disable of [0, false, '0']) assert.equal(isLocalDisabled({ disable }), false)
open({ disable: 1 }, 'bind')
await submit()
assert.equal(notices, 1)
assert.equal(posts, 0)
assert.match(drawerError.value, /停用/)
})
test('all row write actions are disabled while audit remains readable with an explanation', () => {
const actions = source.split('<el-table-column label="操作"')[1].split('</el-table>')[0]
assert.ok((actions.match(/isLocalDisabled\(row\)/g) || []).length >= 5)
assert.match(actions, /command="audit">审计记录/)
assert.match(actions, /停用.*审计/)
assert.match(source, /isLocalDisabled\(selected\)/)
})
+92 -5
View File
@@ -51,10 +51,13 @@
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="account" label="本地账号" min-width="140" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column label="部门" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ departmentText(row) }}</template>
</el-table-column>
<el-table-column label="本地状态" width="110"
><template #default="{ row }"
><el-tag :type="row.disable ? 'danger' : 'success'">{{
row.disable ? '停用' : row.root ? '敏感账号' : '正常'
><el-tag :type="isLocalDisabled(row) ? 'danger' : 'success'">{{
isLocalDisabled(row) ? '停用' : row.root ? '敏感账号' : '正常'
}}</el-tag></template
></el-table-column
>
@@ -76,12 +79,23 @@
</div></template
></el-table-column
>
<el-table-column label="应用访问状态" width="130">
<template #default="{ row }">
<el-tag :type="applicationAccess(row).type">{{
applicationAccess(row).text
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="最近校验时间" min-width="190">
<template #default="{ row }">{{ checkedAtText(row.iam?.checked_at) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="210"
><template #default="{ row }">
<el-button
link
type="primary"
:disabled="
isLocalDisabled(row) ||
(!!row.root && !reviewAllowed) ||
row.iam.state === 'verified' ||
!can(row.iam.state === 'revoked' ? 'rebind' : 'bind')
@@ -93,6 +107,7 @@
link
type="primary"
:disabled="
isLocalDisabled(row) ||
(!!row.root && !reviewAllowed) ||
row.iam.state === 'verified' ||
!can('create') ||
@@ -107,18 +122,29 @@
><el-dropdown-menu>
<el-dropdown-item
command="rebind"
:disabled="(!!row.root && !reviewAllowed) || !can('rebind')"
:disabled="
isLocalDisabled(row) ||
(!!row.root && !reviewAllowed) ||
!can('rebind')
"
>换绑</el-dropdown-item
>
<el-dropdown-item
command="unbind"
:disabled="(!!row.root && !reviewAllowed) || !can('unbind')"
:disabled="
isLocalDisabled(row) ||
(!!row.root && !reviewAllowed) ||
!can('unbind')
"
>解绑</el-dropdown-item
>
<el-dropdown-item command="audit">审计记录</el-dropdown-item>
</el-dropdown-menu></template
></el-dropdown
>
<div v-if="isLocalDisabled(row)" class="text-xs text-gray-500 mt-1">
本地账号已停用,关联操作不可用;审计记录仍可查看。
</div>
</template></el-table-column
>
</el-table>
@@ -285,7 +311,10 @@
type="primary"
:loading="busy"
:disabled="
!confirmed || !operation.reason.trim() || result?.status === 'succeeded'
isLocalDisabled(selected) ||
!confirmed ||
!operation.reason.trim() ||
result?.status === 'succeeded'
"
@click="submit"
>{{
@@ -455,6 +484,56 @@ const validIdentity = computed(
? !!newEmployee.username.trim() && !!newEmployee.display_name.trim()
: !!operation.employee_id)
)
function isLocalDisabled(row: any): boolean {
return row?.disable === true || row?.disable === 1 || row?.disable === '1'
}
function departmentText(row: any): string {
if (!Array.isArray(row?.dept_names)) return '未知'
if (row.dept_names.length === 0) return '未分配'
if (!row.dept_names.every((name: unknown) => typeof name === 'string' && name.trim()))
return '未知'
return row.dept_names.map((name: string) => name.trim()).join('、')
}
function applicationAccess(row: any): {
text: string
type: 'success' | 'warning' | 'info' | 'danger'
} {
const iam = row?.iam
const reason = iam?.access?.reason
if (['SERVICE_UNAVAILABLE', 'STATUS_UNAVAILABLE'].includes(reason)) {
return { text: '服务异常', type: 'warning' }
}
if (reason === 'NOT_AUTHORIZED') return { text: '未授权', type: 'warning' }
if (reason === 'ACCOUNT_DISABLED') return { text: 'IAM账号停用', type: 'danger' }
if (reason === 'BINDING_CONFLICT') return { text: '关联冲突', type: 'warning' }
if (reason === 'ACCOUNT_MISMATCH') return { text: '身份不匹配', type: 'warning' }
if (
['unlinked', 'revoked'].includes(iam?.state) ||
['NOT_LINKED', 'LOCAL_ACCOUNT_LINK_REQUIRED', 'BINDING_REVOKED'].includes(reason)
) {
return { text: '未关联', type: 'info' }
}
if (iam?.access?.allowed === false) return { text: '未授权', type: 'warning' }
if (iam?.state === 'verified' && iam?.access?.allowed === true) {
return { text: '已授权', type: 'success' }
}
return { text: '未知', type: 'info' }
}
function checkedAtText(value: unknown): string {
if (value == null || value === '' || value === 0 || value === '0') return '未校验'
const seconds = typeof value === 'number' || typeof value === 'string' ? Number(value) : NaN
if (!Number.isInteger(seconds) || seconds <= 0 || seconds > 253402300799) return '未知'
return new Date(seconds * 1000).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
async function load() {
loading.value = true
try {
@@ -473,6 +552,10 @@ function reload() {
void load()
}
function open(row: any, kind: IdentityOperation['kind']) {
if (isLocalDisabled(row)) {
ElMessage.warning('本地账号已停用,请先启用账号后再维护关联。')
return
}
if (kind === 'bind' && row.iam.state === 'revoked') kind = 'rebind'
selected.value = row
Object.assign(operation, {
@@ -519,6 +602,10 @@ function choose(row: any) {
}
}
async function submit() {
if (isLocalDisabled(selected.value)) {
drawerError.value = '本地账号已停用,请先启用账号后再维护关联。'
return
}
busy.value = true
try {
if (!submittedPayload.value) {