feat: 支持平台代登录客户工作台
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
API_PROXY_TARGET=http://8.163.30.128:9000
|
API_PROXY_TARGET=http://8.163.30.128:9000
|
||||||
|
VITE_ADMIN_WEB_ORIGIN=http://localhost:5173
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ RUN pnpm config set registry https://registry.npmmirror.com && \
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ARG VITE_ADMIN_WEB_ORIGIN
|
||||||
|
ENV VITE_ADMIN_WEB_ORIGIN=${VITE_ADMIN_WEB_ORIGIN}
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# 运行阶段仅保留构建产物与 Nginx。
|
# 运行阶段仅保留构建产物与 Nginx。
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-http://8.163.30.128:9001}"
|
||||||
image: client-web-test:local
|
image: client-web-test:local
|
||||||
container_name: client-web-test
|
container_name: client-web-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+32
-3
@@ -1,4 +1,33 @@
|
|||||||
<template>
|
<script setup lang="ts">
|
||||||
<RouterView />
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
</template>
|
|
||||||
|
|
||||||
|
let readyInterval: number | null = null
|
||||||
|
onMounted(() => {
|
||||||
|
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
|
||||||
|
const targetOrigin = configuredAdminOrigin || window.location.origin
|
||||||
|
const announceReady = () => window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-ready' }, targetOrigin)
|
||||||
|
announceReady()
|
||||||
|
readyInterval = window.setInterval(announceReady, 300)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (readyInterval !== null) window.clearInterval(readyInterval)
|
||||||
|
readyInterval = null
|
||||||
|
}, 10000)
|
||||||
|
window.addEventListener('message', stopReadyAnnouncement)
|
||||||
|
})
|
||||||
|
|
||||||
|
function stopReadyAnnouncement(event: MessageEvent<unknown>) {
|
||||||
|
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
|
||||||
|
const expectedAdminOrigin = configuredAdminOrigin || window.location.origin
|
||||||
|
const message = event.data as { source?: string; type?: string } | null
|
||||||
|
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || readyInterval === null) return
|
||||||
|
window.clearInterval(readyInterval)
|
||||||
|
readyInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (readyInterval !== null) window.clearInterval(readyInterval)
|
||||||
|
window.removeEventListener('message', stopReadyAnnouncement)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template><RouterView /></template>
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export function persistTokenPair(tokens: TokenPair, remember?: boolean) {
|
|||||||
storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function persistTemporaryAccessToken(accessToken: string) {
|
||||||
|
clearSessionTokens()
|
||||||
|
sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
export function clearSessionTokens() {
|
export function clearSessionTokens() {
|
||||||
for (const storage of [localStorage, sessionStorage]) {
|
for (const storage of [localStorage, sessionStorage]) {
|
||||||
storage.removeItem(ACCESS_TOKEN_KEY)
|
storage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export interface RegisterMerchantResult {
|
|||||||
nextStep: string
|
nextStep: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImpersonationExchangeResult {
|
||||||
|
tokenType: string
|
||||||
|
accessToken: string
|
||||||
|
expiresIn: number
|
||||||
|
}
|
||||||
|
|
||||||
export function loginMerchant(username: string, password: string) {
|
export function loginMerchant(username: string, password: string) {
|
||||||
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { username, password } })
|
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { username, password } })
|
||||||
}
|
}
|
||||||
@@ -36,3 +42,7 @@ export function logoutMerchant(refreshToken: string) {
|
|||||||
export function changeCurrentPassword(oldPassword: string, newPassword: string) {
|
export function changeCurrentPassword(oldPassword: string, newPassword: string) {
|
||||||
return apiRequest<void>({ method: 'PUT', url: '/auth/password', data: { oldPassword, newPassword } })
|
return apiRequest<void>({ method: 'PUT', url: '/auth/password', data: { oldPassword, newPassword } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exchangeImpersonationCode(code: string) {
|
||||||
|
return apiRequest<ImpersonationExchangeResult>({ method: 'POST', url: '/auth/impersonations/exchange', data: { code } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { CurrentUser } from '@chongchong/shared'
|
import type { CurrentUser } from '@chongchong/shared'
|
||||||
import { loginMerchant, logoutMerchant } from '@/modules/auth/api/auth'
|
import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth'
|
||||||
import { ApiError } from '@/api/http'
|
import { ApiError } from '@/api/http'
|
||||||
import {
|
import {
|
||||||
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
updateMerchantPersonalProfile,
|
updateMerchantPersonalProfile,
|
||||||
type MerchantPermissionResource,
|
type MerchantPermissionResource,
|
||||||
} from '@/modules/tenant-management/public'
|
} from '@/modules/tenant-management/public'
|
||||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair } from '@/api/session'
|
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
|
||||||
import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
|
import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
|
||||||
|
|
||||||
const frontendPermissionCodes = new Set(TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code)))
|
const frontendPermissionCodes = new Set(TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code)))
|
||||||
@@ -188,6 +188,27 @@ export const useAuthStore = defineStore('client-auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function acceptImpersonationCode(code: string) {
|
||||||
|
const exchanged = await exchangeImpersonationCode(code)
|
||||||
|
const decoded = decodeMerchantToken(exchanged.accessToken)
|
||||||
|
persistTemporaryAccessToken(exchanged.accessToken)
|
||||||
|
token.value = exchanged.accessToken
|
||||||
|
payload.value = decoded
|
||||||
|
user.value = currentUser(decoded)
|
||||||
|
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
|
||||||
|
permissionCodes.value = mapPermissions(decoded)
|
||||||
|
sessionRestored.value = false
|
||||||
|
try {
|
||||||
|
await loadSessionContext()
|
||||||
|
} catch (error) {
|
||||||
|
if (isExpiredSessionError(error)) {
|
||||||
|
clearSession()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionRestored.value = true
|
||||||
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
const refreshToken = getRefreshToken()
|
const refreshToken = getRefreshToken()
|
||||||
try {
|
try {
|
||||||
@@ -220,9 +241,18 @@ export const useAuthStore = defineStore('client-auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('cc-client-session-expired', clearSession)
|
window.addEventListener('cc-client-session-expired', clearSession)
|
||||||
|
window.addEventListener('message', (event: MessageEvent<unknown>) => {
|
||||||
|
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
|
||||||
|
const expectedAdminOrigin = configuredAdminOrigin || window.location.origin
|
||||||
|
const message = event.data as { source?: string; type?: string; code?: string } | null
|
||||||
|
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code) return
|
||||||
|
void acceptImpersonationCode(message.code)
|
||||||
|
.then(() => window.location.replace('/'))
|
||||||
|
.catch(() => window.location.replace('/login?impersonation=failed'))
|
||||||
|
})
|
||||||
window.addEventListener('storage', (event) => {
|
window.addEventListener('storage', (event) => {
|
||||||
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
|
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, updateProfile, savePersonalProfile }
|
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user