This commit is contained in:
2026-08-10 12:24:18 +08:00
parent 79435fe474
commit 88788dc473
7 changed files with 30 additions and 26 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ pnpm test
一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到测试环境 `http://8.163.30.128:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限取 JWT 声明与后端启用权限资源的交集。前端权限仅用于导航和交互控制,服务端仍须按用户、租户和权限编码完成最终授权。
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
登录表单在开发和生产环境均默认留空;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。
+13 -19
View File
@@ -6,11 +6,10 @@ import { ApiError } from '@/api/http'
import {
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
TENANT_PERMISSION_GROUPS,
getMerchantPermissionResources,
getCurrentMerchantPermissionCodes,
getMerchantPersonalProfile,
getTenantProfile,
updateMerchantPersonalProfile,
type MerchantPermissionResource,
} from '@/modules/tenant-management/public'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
@@ -41,15 +40,8 @@ const emptyProfile: ClientAccountProfile = {
tenantName: '',
}
function effectiveBackendPermissions(payload: MerchantJwtPayload, resources?: MerchantPermissionResource[]) {
const jwtPermissions = new Set(payload.permissions ?? [])
if (!resources) return jwtPermissions
const activeResources = new Set(resources.filter((item) => item.status === 'ACTIVE').map((item) => item.permissionCode))
return new Set([...jwtPermissions].filter((permission) => activeResources.has(permission)))
}
function mapPermissions(payload: MerchantJwtPayload, resources?: MerchantPermissionResource[]) {
const backendPermissions = effectiveBackendPermissions(payload, resources)
function mapPermissions(backendPermissionCodes: string[] = []) {
const backendPermissions = new Set(backendPermissionCodes)
const result = new Set<string>(['dashboard:view', 'account:profile:view'])
for (const permission of backendPermissions) {
if (frontendPermissionCodes.has(permission)) result.add(permission)
@@ -64,7 +56,7 @@ function currentUser(payload: MerchantJwtPayload, tenantName?: string): CurrentU
name: payload.username,
account: payload.username,
tenantName: tenantName || `企业租户 ${payload.tenant_id}`,
roles: payload.roles?.length ? [...payload.roles] : ['企业成员'],
roles: [],
}
}
@@ -80,14 +72,14 @@ export const useAuthStore = defineStore('client-auth', () => {
const permissionCodes = ref<string[]>([])
const sessionRestored = ref(false)
function applyToken(accessToken: string, resources?: MerchantPermissionResource[]) {
function applyToken(accessToken: string) {
const decoded = decodeMerchantToken(accessToken)
payload.value = decoded
token.value = accessToken
user.value = currentUser(decoded, user.value?.tenantName)
profile.value.username = decoded.username
profile.value.realName = decoded.username
permissionCodes.value = mapPermissions(decoded, resources)
permissionCodes.value = mapPermissions()
}
function clearSession() {
@@ -131,12 +123,14 @@ export const useAuthStore = defineStore('client-auth', () => {
async function loadSessionContext() {
if (!payload.value) return
const [resources, tenant, personalProfile] = await Promise.all([
getMerchantPermissionResources(),
const currentPermissionCodes = await getCurrentMerchantPermissionCodes()
const currentAccessToken = getAccessToken()
if (currentAccessToken && currentAccessToken !== token.value) applyToken(currentAccessToken)
permissionCodes.value = mapPermissions(currentPermissionCodes)
const [tenant, personalProfile] = await Promise.all([
getTenantProfile(),
getMerchantPersonalProfile(),
])
permissionCodes.value = mapPermissions(payload.value, resources)
user.value = currentUser(payload.value, tenant.tenantName)
profile.value = {
...profile.value,
@@ -174,7 +168,7 @@ export const useAuthStore = defineStore('client-auth', () => {
payload.value = decoded
user.value = currentUser(decoded)
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
permissionCodes.value = mapPermissions(decoded)
permissionCodes.value = mapPermissions()
sessionRestored.value = false
try {
await loadSessionContext()
@@ -196,7 +190,7 @@ export const useAuthStore = defineStore('client-auth', () => {
payload.value = decoded
user.value = currentUser(decoded)
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
permissionCodes.value = mapPermissions(decoded)
permissionCodes.value = mapPermissions()
sessionRestored.value = false
try {
await loadSessionContext()
+5 -3
View File
@@ -13,12 +13,10 @@ const payload = {
tenant_id: 2001,
membership_id: 3001,
app_code: 'MERCHANT',
roles: ['MERCHANT_OWNER'],
permissions: ['merchant:profile:view'],
exp: 1900000000,
}
test('decodes a merchant JWT payload for UI identity and permissions', () => {
test('decodes a merchant JWT payload containing identity without roles or permissions', () => {
assert.deepEqual(decodeMerchantToken(tokenFor(payload)), payload)
})
@@ -29,3 +27,7 @@ test('rejects tokens from the platform application domain', () => {
test('rejects malformed tokens', () => {
assert.throws(() => decodeMerchantToken('not-a-jwt'), /登录凭证格式无效/)
})
test('rejects expired tokens', () => {
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), /登录状态已过期/)
})
+2 -3
View File
@@ -6,8 +6,6 @@ export interface MerchantJwtPayload {
membership_id: number | string
app_code: string
client_type?: string
roles?: string[]
permissions?: string[]
exp: number
}
@@ -29,9 +27,10 @@ export function decodeMerchantToken(token: string): MerchantJwtPayload {
const payload = value as Partial<MerchantJwtPayload>
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
if (payload.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
return payload as MerchantJwtPayload
} catch (error) {
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
if (error instanceof Error && ['账号不属于当前应用端', '登录状态已过期'].includes(error.message)) throw error
throw new Error('登录凭证无法解析,请重新登录')
}
}
@@ -20,6 +20,10 @@ test('declares the documented merchant role and member endpoints', () => {
assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}\/status`/)
assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}\/password`/)
assert.match(source, /url: '\/merchant\/v1\/iam\/profile'/)
assert.match(source, /url: '\/merchant\/v1\/iam\/authorization\/permissions'/)
assert.match(authSource, /getCurrentMerchantPermissionCodes\(\)/)
assert.doesNotMatch(authSource, /payload\.permissions/)
assert.doesNotMatch(authSource, /payload\.roles/)
})
test('supports tenant profile updates and authenticated qualification downloads', () => {
@@ -145,6 +145,10 @@ export function getMerchantPermissionResources() {
return apiRequest<MerchantPermissionResource[]>({ method: 'GET', url: '/merchant/v1/iam/permissions' })
}
export function getCurrentMerchantPermissionCodes() {
return apiRequest<string[]>({ method: 'GET', url: '/merchant/v1/iam/authorization/permissions' })
}
export function getMerchantPersonalProfile() {
return apiRequest<MerchantPersonalProfile>({ method: 'GET', url: '/merchant/v1/iam/profile' })
}
+1
View File
@@ -1,5 +1,6 @@
export {
getMerchantPermissionResources,
getCurrentMerchantPermissionCodes,
getMerchantPersonalProfile,
getTenantProfile,
updateMerchantPersonalProfile,