202 lines
8.0 KiB
Vue
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>
|