diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 5f8a9f19..fbe6f66f 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir, writeFile } from 'node:fs/promises'; const response = (data: unknown) => ({ result: { data } }); const operationNames = (route: Route): string[] => { @@ -43,6 +44,7 @@ const installFixture = async ( gameIsUnited?: number; openerOnly?: boolean; diagnosticsFixture?: boolean; + auditScopes?: string[]; } = {} ) => { let requested = false; @@ -86,6 +88,10 @@ const installFixture = async ( installActive = true; } const results = operations.map((operation) => { + if (operation === 'auth.issueGameSession') { + requestBodies.push(body); + return response({ profile: 'hwe:default', gameToken: 'audit-fixture-token' }); + } if (operation === 'admin.profiles.diagnostics' && options.diagnosticsFixture) { diagnosticReads += 1; return response({ @@ -154,6 +160,18 @@ const installFixture = async ( } if (operation === 'admin.capabilities.list') { const capabilities = [ + ...(options.auditScopes + ? [ + { + permission: 'admin.playAudit.read', + label: '플레이 감사', + description: '조회', + risk: 'HIGH', + scope: 'PROFILE', + scopes: options.auditScopes, + }, + ] + : []), { permission: 'admin.users.manage', label: '사용자·제재 관리', @@ -683,3 +701,76 @@ test('runtime diagnostics shows expired lease and retains history after recovery await expect(panel).toContainText('턴 프로세스와 실행 권한 정상'); await expect(panel).toContainText('Heartbeat deadline exceeded'); }); + +for (const width of [1280, 390]) { + test(`play audit entry uses scoped capability and private session transfer at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + const fixture = await installFixture(page, { auditScopes: ['hwe:default'] }); + await page.route('**/hwe/play-audit', (route) => + route.fulfill({ contentType: 'text/html', body: '

감사 도착

' }) + ); + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + const button = page.getByRole('button', { name: '플레이 감사', exact: true }); + await expect(button).toBeVisible(); + await button.scrollIntoViewIfNeeded(); + await button.hover(); + await button.focus(); + await expect(button).toBeFocused(); + await page.evaluate(() => document.fonts.ready); + const geometry = await button.evaluate((node) => { + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + return { + x: rect.x, + width: rect.width, + right: rect.right, + viewport: innerWidth, + fontSize: style.fontSize, + background: style.backgroundColor, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(geometry.right).toBeLessThanOrEqual(width); + await mkdir('/tmp/play-audit-entry', { recursive: true }); + await writeFile(`/tmp/play-audit-entry/${width}.json`, JSON.stringify(geometry)); + await writeFile(`/tmp/play-audit-entry/${width}.html`, await page.content()); + await page.screenshot({ path: `/tmp/play-audit-entry/${width}.png`, fullPage: true }); + await button.click(); + await expect(page).toHaveURL(/\/hwe\/play-audit$/); + expect(fixture.requestBodies).toHaveLength(1); + expect(JSON.stringify(fixture.requestBodies[0])).toContain('hwe:default'); + expect( + await page.evaluate(() => { + const transfer = JSON.parse(sessionStorage.getItem('sammo-pending-game-session') ?? '{}'); + return transfer.profile === 'hwe:default' && transfer.gatewayToken === 'audit-fixture-token'; + }) + ).toBe(true); + }); +} + +test('play audit entry is hidden for another profile scope', async ({ page }) => { + const fixture = await installFixture(page, { auditScopes: ['che:default'] }); + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + await expect(page.getByRole('heading', { name: '서버 관리', exact: true, level: 1 })).toBeVisible(); + await expect(page.getByText('현재 시나리오: 1010', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '플레이 감사', exact: true })).toHaveCount(0); + expect(fixture.requestBodies).toHaveLength(0); +}); + +test('play audit storage failure keeps credentials out of the URL and allows retry', async ({ page }) => { + await installFixture(page, { auditScopes: ['hwe:default'] }); + await page.addInitScript(() => { + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function (key: string, value: string) { + if (key === 'sammo-pending-game-session') throw new Error('fixture storage denied'); + return original.call(this, key, value); + }; + }); + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + const button = page.getByRole('button', { name: '플레이 감사', exact: true }); + await button.click(); + await expect(page.getByRole('alert')).toContainText('세션 전달'); + await expect(button).toBeEnabled(); + expect(new URL(page.url()).search).toBe(''); + expect(new URL(page.url()).pathname).toContain('/gateway/admin/servers/'); +}); diff --git a/app/gateway-frontend/src/utils/gameEntry.ts b/app/gateway-frontend/src/utils/gameEntry.ts new file mode 100644 index 00000000..debe81ab --- /dev/null +++ b/app/gateway-frontend/src/utils/gameEntry.ts @@ -0,0 +1,38 @@ +import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer'; + +// 기존 로비의 query fallback은 유지하되 새 관리자 진입은 sessionStorage 전달만 허용한다. +export const resolveGameUrl = ( + path: string, + profileName: string, + gameToken: string, + allowLegacyQueryTransfer = true +): string | null => { + const profile = profileName.split(':', 1)[0] ?? profileName; + const baseUrl = + import.meta.env.VITE_GAME_WEB_URL ?? + import.meta.env.VITE_GAME_WEB_URL_TEMPLATE?.replaceAll('{profile}', encodeURIComponent(profile)) ?? + ''; + if (!baseUrl) { + return null; + } + const base = new URL(baseUrl, window.location.origin); + const normalizedPath = path.replace(/^\//, ''); + const url = new URL(normalizedPath, base); + let transferredInSessionStorage = false; + if (url.origin === window.location.origin) { + try { + transferredInSessionStorage = writeGameSessionTransfer(window.sessionStorage, { + profile: profileName, + gatewayToken: gameToken, + }); + } catch { + transferredInSessionStorage = false; + } + } + if (!transferredInSessionStorage) { + if (!allowLegacyQueryTransfer) return null; + url.searchParams.set('profile', profileName); + url.searchParams.set('gameToken', gameToken); + } + return url.toString(); +}; diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index e0611a43..471b537e 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -19,6 +19,7 @@ import { type ResetAutorunOption, } from '../utils/resetDefaults'; import { trpc } from '../utils/trpc'; +import { resolveGameUrl } from '../utils/gameEntry'; type AdminSection = 'users' | 'servers' | 'system' | 'audit'; @@ -571,6 +572,29 @@ const rolesInput = ref(''); const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant'); const rolesStatus = ref(''); const capabilities = ref([]); +const auditEntryLoading = ref>({}); +const auditEntryError = ref>({}); +const enterPlayAudit = async (profileName: string): Promise => { + if (auditEntryLoading.value[profileName] || !hasCapability('admin.playAudit.read', profileName)) return; + auditEntryLoading.value[profileName] = true; + auditEntryError.value[profileName] = ''; + try { + const sessionToken = window.localStorage.getItem('sammo-session-token'); + if (!sessionToken) throw new Error('로그인 후 다시 시도해 주세요.'); + const issued = await trpc.auth.issueGameSession.mutate({ sessionToken, profile: profileName }); + const url = resolveGameUrl('/play-audit', issued.profile, issued.gameToken, false); + if (!url) + throw new Error( + '게임 주소 또는 세션 전달을 확인하지 못했습니다. 같은 사이트에서 쿠키·저장소를 허용한 뒤 다시 시도해 주세요.' + ); + window.location.assign(url); + } catch (cause) { + auditEntryError.value[profileName] = + cause instanceof Error ? cause.message : '플레이 감사 화면에 연결하지 못했습니다.'; + } finally { + auditEntryLoading.value[profileName] = false; + } +}; const hasCapability = (permission: string, profileName?: string): boolean => capabilities.value.some((entry) => { if (entry.permission !== permission) return false; @@ -2418,6 +2442,23 @@ onMounted(() => { :can-reset="hasCapability('admin.scenarios.reset', profile.profileName)" :can-cancel="hasCapability('admin.games.cancel', profile.profileName)" /> +
+ + +
diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index b5071824..5df0c276 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -4,7 +4,7 @@ import { computed, ref, onMounted, onUnmounted, watch } from 'vue'; import { useRouter } from 'vue-router'; import type { inferRouterOutputs } from '@trpc/server'; import type { AppRouter } from '@sammo-ts/gateway-api'; -import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer'; +import { resolveGameUrl } from '../utils/gameEntry'; import DefaultLayout from '../layouts/DefaultLayout.vue'; import MapPreview from '../components/MapPreview.vue'; import ProfilePreopenAnnouncement from '../components/ProfilePreopenAnnouncement.vue'; @@ -338,36 +338,6 @@ const handleKakaoVerification = async (): Promise => { } }; -const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => { - const profile = profileName.split(':', 1)[0] ?? profileName; - const baseUrl = - import.meta.env.VITE_GAME_WEB_URL ?? - import.meta.env.VITE_GAME_WEB_URL_TEMPLATE?.replaceAll('{profile}', encodeURIComponent(profile)) ?? - ''; - if (!baseUrl) { - return null; - } - const base = new URL(baseUrl, window.location.origin); - const normalizedPath = path.replace(/^\//, ''); - const url = new URL(normalizedPath, base); - let transferredInSessionStorage = false; - if (url.origin === window.location.origin) { - try { - transferredInSessionStorage = writeGameSessionTransfer(window.sessionStorage, { - profile: profileName, - gatewayToken: gameToken, - }); - } catch { - transferredInSessionStorage = false; - } - } - if (!transferredInSessionStorage) { - url.searchParams.set('profile', profileName); - url.searchParams.set('gameToken', gameToken); - } - return url.toString(); -}; - const handleEnter = async (profile: LobbyProfile, targetPath: string) => { if (entryLoading.value[profile.profileName]) { return; diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 510d88cd..10a3bf4a 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -29,9 +29,17 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라 이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음, 넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다. 월말/FINAL 장수·도시 projection을 보여주지만 국가 FINAL 별도 시계열, 지도, -로그/예약 명령/전투 상세, 검색·정렬, 관리자 패널 진입 버튼은 후속 구현으로 남는다. +로그/예약 명령/전투 상세, 검색·정렬은 후속 구현으로 남는다. 따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다. +Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체 +profile scope가 있을 때만 진입 버튼을 표시한다. 기존 `auth.issueGameSession` 발급과 +game session transfer를 사용하고 Gateway가 감사 데이터를 대신 읽지 않는다. +기존 로비의 URL 구성/세션 전달을 `utils/gameEntry.ts`로 추출해 공유한다. +새 감사 진입은 동일 origin의 sessionStorage 전달만 허용하며, 실패하면 현재 화면에 +재시도 가능한 오류를 표시한다. 기존 로비의 query fallback은 동작 변경 없이 유지하되 +새 감사 경로에는 적용하지 않는다. 서로 다른 origin의 관리자 진입은 지원하지 않는다. + `app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로 허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각 한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도