diff --git a/.env.example b/.env.example index b43cf89..64e1af2 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ VITE_API_BASE_URL=/api API_PROXY_TARGET=http://8.163.30.128:9000 +VITE_ADMIN_WEB_ORIGIN=http://localhost:5173 diff --git a/Dockerfile b/Dockerfile index 4d48142..a747a6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,8 @@ RUN pnpm config set registry https://registry.npmmirror.com && \ pnpm install --frozen-lockfile COPY . . +ARG VITE_ADMIN_WEB_ORIGIN +ENV VITE_ADMIN_WEB_ORIGIN=${VITE_ADMIN_WEB_ORIGIN} RUN pnpm build # 运行阶段仅保留构建产物与 Nginx。 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index b463d8b..3404d87 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -3,6 +3,8 @@ services: build: context: . dockerfile: Dockerfile + args: + VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-http://8.163.30.128:9001}" image: client-web-test:local container_name: client-web-test restart: unless-stopped diff --git a/src/App.vue b/src/App.vue index 4be8965..f38f8d2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,33 @@ - + + + diff --git a/src/api/session.ts b/src/api/session.ts index c3d38f6..7c01340 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -29,6 +29,11 @@ export function persistTokenPair(tokens: TokenPair, remember?: boolean) { storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) } +export function persistTemporaryAccessToken(accessToken: string) { + clearSessionTokens() + sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken) +} + export function clearSessionTokens() { for (const storage of [localStorage, sessionStorage]) { storage.removeItem(ACCESS_TOKEN_KEY) diff --git a/src/modules/auth/api/auth.ts b/src/modules/auth/api/auth.ts index b310ef0..f0fc43e 100644 --- a/src/modules/auth/api/auth.ts +++ b/src/modules/auth/api/auth.ts @@ -21,6 +21,12 @@ export interface RegisterMerchantResult { nextStep: string } +export interface ImpersonationExchangeResult { + tokenType: string + accessToken: string + expiresIn: number +} + export function loginMerchant(username: string, password: string) { return apiRequest({ method: 'POST', url: '/auth/login', data: { username, password } }) } @@ -36,3 +42,7 @@ export function logoutMerchant(refreshToken: string) { export function changeCurrentPassword(oldPassword: string, newPassword: string) { return apiRequest({ method: 'PUT', url: '/auth/password', data: { oldPassword, newPassword } }) } + +export function exchangeImpersonationCode(code: string) { + return apiRequest({ method: 'POST', url: '/auth/impersonations/exchange', data: { code } }) +} diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 51760be..5ce4c28 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -1,7 +1,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' 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 { FRONTEND_TO_MERCHANT_PERMISSION_CODES, @@ -12,7 +12,7 @@ import { updateMerchantPersonalProfile, type MerchantPermissionResource, } 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' 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() { const refreshToken = getRefreshToken() try { @@ -220,9 +241,18 @@ export const useAuthStore = defineStore('client-auth', () => { } window.addEventListener('cc-client-session-expired', clearSession) + window.addEventListener('message', (event: MessageEvent) => { + 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) => { 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 } })