Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/apps/site/app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ const userMenuItems = computed<DropdownMenuItem[][]>(() => [
}
],
[
{ label: '个人中心', icon: 'i-lucide-user', to: '/profile' }
{ label: '个人中心', icon: 'i-lucide-user', to: '/profile' },
{ label: '账号安全', icon: 'i-lucide-shield-check', to: '/security' }
],
[
{
Expand Down
59 changes: 59 additions & 0 deletions frontend/apps/site/app/composables/useMemberSecurity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
fetchMemberClientSessions,
revokeMemberClientSession
} from '@mortise/core-sdk'
import type { MemberClientSession } from '~/types/security'

export function useMemberSecurity() {
const { $api } = useNuxtApp()
const sessions = useState<MemberClientSession[]>('site-member-client-sessions', () => [])
const loading = useState('site-member-security-loading', () => false)
const error = useState('site-member-security-error', () => '')

async function fetchSessions(): Promise<MemberClientSession[]> {
loading.value = true
error.value = ''
try {
sessions.value = await fetchMemberClientSessions($api)
return sessions.value
}
catch (e) {
error.value = e instanceof Error ? e.message : '获取设备会话失败'
return []
}
finally {
loading.value = false
}
}

async function revokeSession(sessionId: string): Promise<boolean> {
loading.value = true
error.value = ''
try {
const ok = await revokeMemberClientSession($api, sessionId)
if (ok) {
sessions.value = sessions.value.map(session =>
session.id === sessionId
? { ...session, status: 'revoked', revokedAt: new Date().toISOString() }
: session
)
}
return ok
}
catch (e) {
error.value = e instanceof Error ? e.message : '撤销设备会话失败'
return false
}
finally {
loading.value = false
}
}

return {
sessions: readonly(sessions),
loading: readonly(loading),
error: readonly(error),
fetchSessions,
revokeSession
}
}
132 changes: 132 additions & 0 deletions frontend/apps/site/app/pages/auth/desktop/authorize.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { authorizeDesktopClient } from '@mortise/core-sdk'

definePageMeta({
layout: 'auth',
middleware: 'auth'
})

useSeoMeta({
title: '授权 Rodak',
description: '确认是否授权 Rodak 桌面端访问您的 Mortise 账号'
})

const route = useRoute()
const api = useNuxtApp().$api
const auth = useAuthStore()
const loading = ref(false)
const errorMessage = ref('')

const clientId = computed(() => stringQuery('client_id'))
const redirectUri = computed(() => stringQuery('redirect_uri'))
const state = computed(() => stringQuery('state'))
const codeChallenge = computed(() => stringQuery('code_challenge'))
const codeChallengeMethod = computed(() => stringQuery('code_challenge_method') || 'S256')
const scope = computed(() => stringQuery('scope') || 'profile')
const deviceName = computed(() => stringQuery('device_name') || 'Rodak')
const deviceFingerprint = computed(() => stringQuery('device_fingerprint'))

const canAuthorize = computed(() =>
Boolean(clientId.value && redirectUri.value && state.value && codeChallenge.value)
)

function stringQuery(key: string): string {
const value = route.query[key]
return typeof value === 'string' ? value : ''
}

async function handleAuthorize() {
errorMessage.value = ''
if (!canAuthorize.value) {
errorMessage.value = '授权请求缺少必要参数。'
return
}

loading.value = true
try {
const response = await authorizeDesktopClient(api, {
client_id: clientId.value,
redirect_uri: redirectUri.value,
state: state.value,
code_challenge: codeChallenge.value,
code_challenge_method: codeChallengeMethod.value,
scope: scope.value,
device_name: deviceName.value,
device_fingerprint: deviceFingerprint.value || undefined
})

if (!response?.redirectUri) {
throw new Error('授权响应缺少回调地址。')
}

await navigateTo(response.redirectUri, { external: true })
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '授权 Rodak 失败。'
} finally {
loading.value = false
}
}
</script>

<template>
<div class="space-y-4">
<UCard>
<template #header>
<div class="flex items-center gap-3">
<UIcon name="i-lucide-monitor-check" class="size-5 text-primary" />
<div>
<h1 class="text-lg font-semibold">授权 Rodak</h1>
<p class="text-sm text-muted">
{{ auth.currentUser?.nickname || auth.currentUser?.username || '当前账号' }}
</p>
</div>
</div>
</template>

<div class="space-y-4">
<p class="text-sm text-muted">
Rodak 桌面端请求访问您的 Mortise 账号,用于同步会员身份并访问已登录接口。
</p>

<div class="rounded-md border border-muted p-3 text-sm">
<div class="flex items-center justify-between gap-4">
<span class="text-muted">客户端</span>
<span class="font-medium">{{ clientId || '未知客户端' }}</span>
</div>
<div class="mt-2 flex items-center justify-between gap-4">
<span class="text-muted">设备</span>
<span class="font-medium">{{ deviceName }}</span>
</div>
<div class="mt-2 flex items-center justify-between gap-4">
<span class="text-muted">权限</span>
<span class="font-medium">{{ scope }}</span>
</div>
</div>

<UAlert
v-if="errorMessage"
color="error"
variant="soft"
icon="i-lucide-circle-alert"
:title="errorMessage"
/>
</div>

<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" to="/">
取消
</UButton>
<UButton
icon="i-lucide-check"
:loading="loading"
:disabled="!canAuthorize"
@click="handleAuthorize"
>
授权并返回 Rodak
</UButton>
</div>
</template>
</UCard>
</div>
</template>
184 changes: 184 additions & 0 deletions frontend/apps/site/app/pages/security.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { SecurityDeviceSummary } from '~/types/security'

definePageMeta({
middleware: 'auth'
})

useSeoMeta({
title: '账号安全中心',
description: '管理已授权的 Rodak 桌面设备'
})

const toast = useToast()
const { sessions, loading, error, fetchSessions, revokeSession } = useMemberSecurity()

await useAsyncData('site-member-security', () => fetchSessions())

const activeSessions = computed(() => sessions.value.filter(session => session.status === 'active'))
const revokedSessions = computed(() => sessions.value.filter(session => session.status !== 'active'))
const summaries = computed<SecurityDeviceSummary[]>(() => [
{
label: '已授权设备',
value: String(sessions.value.length),
icon: 'i-lucide-monitor-check'
},
{
label: '活跃会话',
value: String(activeSessions.value.length),
icon: 'i-lucide-shield-check'
},
{
label: '已撤销',
value: String(revokedSessions.value.length),
icon: 'i-lucide-shield-x'
}
])

async function handleRefresh() {
await fetchSessions()
}

function formatTime(value?: string | null): string {
if (!value) {
return '暂无记录'
}
const date = parseBackendDateTime(value)
if (!date) {
return '暂无记录'
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}

function parseBackendDateTime(value: string): Date | null {
const normalized = value.trim().replace(' ', 'T')
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date
}

function statusLabel(status: string): string {
return status === 'active' ? '使用中' : '已撤销'
}

function statusColor(status: string): 'success' | 'neutral' {
return status === 'active' ? 'success' : 'neutral'
}

async function handleRevoke(sessionId: string) {
const ok = await revokeSession(sessionId)
if (ok) {
toast.add({
title: '设备会话已撤销',
color: 'success',
icon: 'i-lucide-check'
})
}
else if (error.value) {
toast.add({
title: error.value,
color: 'error',
icon: 'i-lucide-circle-alert'
})
}
}
</script>

<template>
<UContainer class="max-w-6xl py-8 sm:py-10">
<section class="mb-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl">账号安全中心</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-muted">
管理通过浏览器授权登录的 Rodak 桌面设备。撤销后,对应设备的刷新令牌会失效。
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
color="neutral"
variant="soft"
:loading="loading"
@click="handleRefresh"
>
刷新
</UButton>
</div>
</section>

<div class="mb-6 grid gap-4 sm:grid-cols-3">
<UCard v-for="item in summaries" :key="item.label">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm text-muted">{{ item.label }}</div>
<div class="mt-2 text-2xl font-semibold">{{ item.value }}</div>
</div>
<UIcon :name="item.icon" class="size-6 text-primary" />
</div>
</UCard>
</div>

<UAlert
v-if="error"
class="mb-6"
color="error"
variant="soft"
icon="i-lucide-circle-alert"
:title="error"
/>

<UCard>
<template #header>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-base font-semibold">Rodak 授权设备</h2>
<p class="mt-1 text-sm text-muted">每台安装设备都会拥有独立会话,退出和撤销互不影响。</p>
</div>
</div>
</template>

<div v-if="!sessions.length && !loading" class="py-12 text-center">
<UIcon name="i-lucide-monitor-off" class="mx-auto size-9 text-muted" />
<p class="mt-3 text-sm text-muted">还没有 Rodak 桌面授权记录</p>
</div>

<div v-else class="divide-y divide-muted">
<div
v-for="session in sessions"
:key="session.id"
class="flex flex-col gap-4 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{{ session.deviceName || '未知设备' }}</span>
<UBadge :color="statusColor(session.status)" variant="soft">
{{ statusLabel(session.status) }}
</UBadge>
</div>
<div class="mt-2 grid gap-1 text-sm text-muted sm:grid-cols-2">
<span>客户端:{{ session.clientName || session.clientId || '未知客户端' }}</span>
<span>最近活跃:{{ formatTime(session.lastActiveAt) }}</span>
<span>授权时间:{{ formatTime(session.createdTime) }}</span>
<span v-if="session.revokedAt">撤销时间:{{ formatTime(session.revokedAt) }}</span>
</div>
</div>
<UButton
v-if="session.status === 'active'"
icon="i-lucide-ban"
color="error"
variant="soft"
:loading="loading"
@click="handleRevoke(session.id)"
>
撤销
</UButton>
</div>
</div>
</UCard>
</UContainer>
</template>
9 changes: 9 additions & 0 deletions frontend/apps/site/app/types/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { MemberClientSession } from '@mortise/core-sdk'

export type { MemberClientSession }

export interface SecurityDeviceSummary {
label: string
value: string
icon: string
}
Loading