Files
oci-portal-dash/src/components/settings/ActiveSessionsCard.vue
T
2026-07-30 12:23:16 +08:00

202 lines
8.0 KiB
Vue

<script setup lang="ts">
import { NButton, NSpin } from 'naive-ui'
import { computed, ref } from 'vue'
import { listSessions, revokeSession, revokeSessions } from '@/api/auth'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FootNote from '@/components/FootNote.vue'
import { useAsync } from '@/composables/useAsync'
import { fmtRelative, fmtTime, uaLabel } from '@/composables/useFormat'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import type { SessionItem } from '@/types/api'
/** 活跃会话卡:全宽表格列出当前有效登录会话,支持定点撤销与撤销全部 */
const message = useToast()
const auth = useAuthStore()
const sessions = useAsync(listSessions)
const items = computed(() => sessions.data.value?.items ?? [])
/** 零行才是空态:单条本机会话也渲染表格(展示设备/IP/时间比空文案更有信息量);
* 零行不证明只有一个活跃会话——升级前签发的旧令牌不产生记录 */
const empty = computed(() => items.value.length === 0)
const methodLabels: Record<string, string> = {
password: '密码',
passkey: '通行密钥',
wallet: '钱包',
oidc: 'OIDC',
github: 'GitHub',
}
function deviceOf(s: SessionItem) {
return uaLabel(s.userAgent)
}
/** 撤销互斥:单个与全部共享——撤销全部在服务端递增版本后、前端写入新
* token 前,并发的单撤销会携带旧 token 吃 401 并触发全局登出竞态 */
const revoking = ref(false)
async function revokeOne(s: SessionItem) {
if (revoking.value) return
revoking.value = true
try {
await revokeSession(s.id)
message.success('已撤销,该设备将立即退出登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '撤销失败')
} finally {
revoking.value = false
void sessions.run({ silent: true })
}
}
/** 撤销全部 = 令牌版本递增:其他设备立即失效,本会话经新 token 无感换新 */
async function revokeAll() {
if (revoking.value) return
revoking.value = true
try {
const r = await revokeSessions()
if (r?.token) auth.setSession(r.token, r.expiresAt)
message.success('已撤销全部会话;其他设备须重新登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
revoking.value = false
void sessions.run({ silent: true })
}
}
</script>
<template>
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">活跃会话</div>
<div class="mt-0.5 text-xs text-ink-3">
当前有效登录会话,最近活跃在前;登出撤销或凭据变更后自动消失
</div>
</div>
<ConfirmPop
title="撤销全部会话?"
content="其他设备与旧令牌立即失效并需重新登录;本会话自动换新。"
confirm-text="全部撤销"
:on-confirm="revokeAll"
>
<template #trigger>
<!-- 不依赖列表禁用(存量令牌唯一失效入口);仅与单撤销互斥 -->
<NButton size="tiny" type="error" quaternary :disabled="revoking">撤销全部会话</NButton>
</template>
</ConfirmPop>
</div>
<div v-if="sessions.loading.value && !sessions.data.value" class="flex justify-center py-8">
<NSpin size="small" />
</div>
<!-- 请求失败与空列表分开呈现,失败不得伪装成仅本机一个会话 -->
<div
v-else-if="sessions.error.value"
class="px-4.5 py-8 text-center text-xs leading-relaxed text-ink-3"
>
会话列表加载失败:{{ sessions.error.value }}
<NButton size="tiny" quaternary type="primary" class="ml-1" @click="() => sessions.run()">
重试
</NButton>
</div>
<div v-else-if="empty" class="px-4.5 py-8 text-center text-xs leading-relaxed text-ink-3">
暂无会话记录升级前签发的旧令牌不产生记录,不代表没有其他活跃会话;
如有顾虑可直接撤销全部会话」。
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-[12.5px]">
<thead>
<tr class="text-left text-xs text-ink-3">
<th class="border-b border-line-soft px-4.5 py-2 font-medium">设备</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">登录方式</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">IP</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">登录时间</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">最近活跃</th>
<th class="w-20 border-b border-line-soft px-3 py-2 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="s in items" :key="s.id" class="[&:last-child>td]:border-b-0">
<td class="border-b border-line-soft px-4.5 py-2.5">
<div class="flex items-center gap-2.5">
<span
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
>
<svg
v-if="deviceOf(s).mobile"
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
>
<rect x="7" y="2" width="10" height="20" rx="2" />
<path d="M11 18h2" />
</svg>
<svg
v-else
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
>
<rect x="3" y="5" width="18" height="12" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
</span>
<span class="font-medium whitespace-nowrap">{{ deviceOf(s).label }}</span>
<span
v-if="s.current"
class="rounded-full bg-ok/12 px-2 py-px text-[11px] font-semibold whitespace-nowrap text-ok"
>
本机
</span>
</div>
</td>
<td class="border-b border-line-soft px-3 py-2.5">
<span
class="rounded-full border border-line bg-white px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
>
{{ methodLabels[s.method] || s.method || '—' }}
</span>
</td>
<td class="mono border-b border-line-soft px-3 py-2.5 text-ink-2">{{ s.clientIp }}</td>
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap text-ink-2">
{{ fmtTime(s.createdAt) }}
</td>
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap text-ink-2">
{{ fmtRelative(s.lastSeenAt) }}
</td>
<td class="border-b border-line-soft px-3 py-2.5">
<span v-if="s.current" class="px-2 text-xs text-ink-3"></span>
<ConfirmPop
v-else
title="撤销该会话?"
:content="`${deviceOf(s).label}(${s.clientIp})将立即退出登录;其他会话不受影响。`"
confirm-text="撤销"
:on-confirm="() => revokeOne(s)"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary :disabled="revoking">撤销</NButton>
</template>
</ConfirmPop>
</td>
</tr>
</tbody>
</table>
</div>
<FootNote>
当前会话不可定点撤销(请用退出登录);升级前签发的存量令牌不在列表中,24h 内自然过期
</FootNote>
</div>
</template>