fix(admin): complete IAM identity status columns
This commit is contained in:
@@ -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\)/)
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user