diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index 49cae7fb..e17b6912 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { writeFile } from 'node:fs/promises'; +import { formatRoleGroups } from '../src/utils/roleDisplay'; const response = (data: unknown) => ({ result: { data } }); const operationNames = (route: Route): string[] => { @@ -7,8 +8,14 @@ const operationNames = (route: Route): string[] => { return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); }; -const installFixture = async (page: Page) => { +const installFixture = async (page: Page, scopes = ['*']) => { const requests: Array<{ operation: string; body: unknown }> = []; + let roles = [ + 'user', + 'admin.profiles.runtime:che:default', + 'admin.profiles.runtime:kwe:default', + 'admin.profiles.runtime:pwe:default', + ]; let deleteAfter: string | null = null; let graceUntil: string | null = null; let username = 'target'; @@ -59,6 +66,7 @@ const installFixture = async (page: Page) => { description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.', risk: 'HIGH', scope: 'PROFILE', + scopes, }, { permission: 'admin.audit.read', @@ -70,6 +78,15 @@ const installFixture = async (page: Page) => { ]); } if (operation === 'admin.audit.list') return response(auditHistory); + if (operation === 'account.notifications.get') + return response({ + capability: { enabled: false, publicKey: null }, + eventTypes: [], + profiles: [], + preferences: [], + subscriptionCount: 0, + currentDeviceSubscribed: false, + }); if (operation === 'admin.users.getLocalAccountStatus') return response({ enabled: false }); if (operation === 'admin.users.list') { return response({ @@ -111,12 +128,20 @@ const installFixture = async (page: Page) => { if (operation === 'admin.profiles.listNavigation') return response([]); if (operation === 'admin.profiles.list') return response([]); if (operation === 'admin.profiles.listScenarios') return response([]); + if (operation === 'admin.users.updateRoles') { + const input = (body as Record)['0']; + roles = + input.mode === 'grant' + ? [...new Set([...roles, ...input.roles])] + : roles.filter((role) => !input.roles.includes(role)); + return response({ roles }); + } if (operation === 'admin.users.lookup') { return response({ id: 'target-user', username, displayName, - roles: ['user'], + roles, sanctions: {}, oauthType: 'KAKAO', oauthId: 'fixture-kakao-stable-id', @@ -329,3 +354,158 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', ); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-mobile.png'), fullPage: true }); }); + +for (const width of [1365, 390]) { + test(`groups server roles and applies all-server action at ${width}px`, async ({ page }, testInfo) => { + const requests = await installFixture(page); + await page.setViewportSize({ width, height: 900 }); + await page.goto('admin/users'); + await page.getByRole('button', { name: /target.*대상 사용자/ }).click(); + const summary = page.getByLabel('보유 권한'); + await expect(summary).toContainText('admin.profiles.runtime:{che, kwe, pwe}:default'); + await page.getByRole('button', { name: /접근 · 권한/ }).click(); + await page.getByLabel('관리 기능 권한').selectOption('admin.profiles.runtime'); + const server = page.getByLabel('권한 대상 서버'); + await server.selectOption('*'); + await expect(server).toHaveValue('*'); + await expect(page.getByText(/선택한 행동만 현재와 앞으로 추가되는 모든 서버에 허용/)).toBeVisible(); + await server.focus(); + await server.click(); + await page.screenshot({ path: testInfo.outputPath('all-servers-selected.png'), fullPage: true }); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: '선택 반영', exact: true }).click(); + const input = page.getByPlaceholder('admin, moderator 등'); + await expect(input).toHaveValue('admin.profiles.runtime:*'); + await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('모든 서버 실행 관리 허용'); + const apply = page.getByRole('button', { name: '적용', exact: true }); + await apply.hover(); + await apply.focus(); + await expect(apply).toBeFocused(); + await apply.click(); + await expect(summary).toContainText('admin.profiles.runtime:* (모든 서버)'); + const update = requests.find(({ operation }) => operation === 'admin.users.updateRoles'); + expect(update?.body).toMatchObject({ + '0': { roles: ['admin.profiles.runtime:*'], mode: 'grant' }, + }); + // 전역 기능은 서버 선택이 없고, 다른 서버로 범위를 암묵적으로 옮기지 않는다. + await page.getByLabel('관리 기능 권한').selectOption('admin.users.manage'); + await expect(server).toHaveCount(0); + await page.getByLabel('관리 기능 권한').selectOption('admin.profiles.runtime'); + await expect(server).toHaveValue(''); + await page.getByRole('button', { name: '선택 반영', exact: true }).click(); + await expect(page.getByText('대상 서버를 선택하세요.', { exact: true }).first()).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + const geometry = await summary.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: rect.right, + width: rect.width, + viewportWidth: innerWidth, + overflowWrap: style.overflowWrap, + documentOverflow: document.documentElement.scrollWidth - innerWidth, + text: element.textContent, + }; + }); + await writeFile(testInfo.outputPath('permission-geometry.json'), JSON.stringify(geometry, null, 2)); + expect(geometry.right).toBeLessThanOrEqual(width); + expect(geometry.documentOverflow).toBe(0); + await writeFile(testInfo.outputPath('permissions.html'), await page.content()); + await page.screenshot({ path: testInfo.outputPath('permissions.png'), fullPage: true }); + }); +} + +test('limits the server selector to the administrators own action scope', async ({ page }) => { + await installFixture(page, ['che:default']); + await page.goto('admin/users'); + await page.getByRole('button', { name: /target.*대상 사용자/ }).click(); + await page.getByRole('button', { name: /접근 · 권한/ }).click(); + await page.getByLabel('관리 기능 권한').selectOption('admin.profiles.runtime'); + const server = page.getByLabel('권한 대상 서버'); + await expect(server.locator('option[value="*"]')).toHaveCount(0); + await server.selectOption('che:default'); + await page.getByRole('button', { name: '선택 반영', exact: true }).click(); + await expect(page.getByPlaceholder('admin, moderator 등')).toHaveValue('admin.profiles.runtime:che:default'); +}); + +test('keeps action and instance boundaries when formatting explicit grants', () => { + const roles = [ + 'user', + 'admin.profiles.runtime:che:default', + 'admin.profiles.runtime:kwe:default', + 'admin.profiles.runtime:che:season2', + 'admin.profiles.runtime:hwe:season2', + 'admin.playAudit.read:che:default', + 'admin.playAudit.accounts', + 'admin.profiles.deploy', + 'admin.profiles.deploy:*', + ]; + expect(formatRoleGroups(roles)).toEqual([ + 'user', + 'admin.profiles.runtime:{che, kwe}:default', + 'admin.profiles.runtime:{che, hwe}:season2', + 'admin.playAudit.read:che:default', + 'admin.playAudit.accounts', + 'admin.profiles.deploy', + 'admin.profiles.deploy:* (모든 서버)', + ]); + expect(roles[1]).toBe('admin.profiles.runtime:che:default'); + expect(formatRoleGroups([])).toEqual([]); +}); + +test('shows grouped roles on the account page after direct navigation and refresh', async ({ page }, testInfo) => { + await installFixture(page); + let showGroupedRoles = false; + await page.route('**/gateway/api/trpc/account.get*', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([ + response({ + id: 'target-user', + username: 'target', + displayName: '대상 사용자', + roles: showGroupedRoles + ? [ + 'user', + ...['che', 'kwe', 'pwe', 'hwe', 'twe', 'nya', 'pya'].flatMap((profile) => [ + `admin.profiles.runtime:${profile}:default`, + `admin.playAudit.read:${profile}:default`, + ]), + 'admin.profiles.deploy:*', + ] + : ['user'], + oauthType: 'NONE', + createdAt: '2026-07-20T00:00:00.000Z', + thirdPartyUse: false, + icons: [], + maxActiveIcons: 5, + deleteAfter: null, + }), + ]), + }); + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('account'); + await expect(page.getByLabel('보유 권한')).toHaveText('user'); + const baselineOverflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth); + showGroupedRoles = true; + await page.reload(); + const summary = page.getByLabel('보유 권한'); + await expect(summary).toContainText('admin.profiles.runtime:{che, kwe, pwe, hwe, twe, nya, pya}:default'); + await expect(summary).toContainText('admin.profiles.deploy:* (모든 서버)'); + await expect(summary).not.toContainText('admin.playAudit.accounts'); + await page.evaluate(() => document.fonts.ready); + const geometry = await summary.evaluate((element) => ({ + right: element.getBoundingClientRect().right, + viewport: innerWidth, + cellRight: element.closest('td')!.getBoundingClientRect().right, + documentOverflow: document.documentElement.scrollWidth - innerWidth, + overflowWrap: getComputedStyle(element).overflowWrap, + })); + expect(geometry.right).toBeLessThanOrEqual(geometry.cellRight); + expect(geometry.documentOverflow).toBe(baselineOverflow); + await writeFile(testInfo.outputPath('account-permissions-geometry.json'), JSON.stringify(geometry, null, 2)); + await writeFile(testInfo.outputPath('account-permissions.html'), await page.content()); + await page.screenshot({ path: testInfo.outputPath('account-permissions.png'), fullPage: true }); +}); diff --git a/app/gateway-frontend/src/components/RoleSummary.vue b/app/gateway-frontend/src/components/RoleSummary.vue new file mode 100644 index 00000000..0a07b420 --- /dev/null +++ b/app/gateway-frontend/src/components/RoleSummary.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/app/gateway-frontend/src/utils/roleDisplay.ts b/app/gateway-frontend/src/utils/roleDisplay.ts new file mode 100644 index 00000000..767cf818 --- /dev/null +++ b/app/gateway-frontend/src/utils/roleDisplay.ts @@ -0,0 +1,26 @@ +/** 표시만 묶는다. 명시적인 서버 권한을 모든 서버 권한으로 승격하지 않는다. */ +export const formatRoleGroups = (roles: readonly string[]): string[] => { + const groups = new Map(); + const entries: Array = []; + for (const role of roles) { + const match = /^(admin\.[^:]+):([^:]+):([^:]+)$/u.exec(role); + if (!match) { + entries.push(role.endsWith(':*') ? `${role} (모든 서버)` : role); + continue; + } + const [, permission, profile, instance] = match; + const key = `${permission}:${instance}`; + let group = groups.get(key); + if (!group) { + group = { permission, instance, profiles: [] }; + groups.set(key, group); + entries.push(group); + } + if (!group.profiles.includes(profile)) group.profiles.push(profile); + } + return entries.map((entry) => { + if (typeof entry === 'string') return entry; + const profiles = entry.profiles.length > 1 ? `{${entry.profiles.join(', ')}}` : entry.profiles[0]; + return `${entry.permission}:${profiles}:${entry.instance}`; + }); +}; diff --git a/app/gateway-frontend/src/views/AccountView.vue b/app/gateway-frontend/src/views/AccountView.vue index cc9a71da..4f7bb302 100644 --- a/app/gateway-frontend/src/views/AccountView.vue +++ b/app/gateway-frontend/src/views/AccountView.vue @@ -5,6 +5,7 @@ import { useRouter } from 'vue-router'; import { useToast } from '../composables/useToast'; import DefaultLayout from '../layouts/DefaultLayout.vue'; +import RoleSummary from '../components/RoleSummary.vue'; import { createGameTrpc } from '../utils/gameTrpc'; import { trpc } from '../utils/trpc'; import { sealPassword } from '../utils/passwordEnvelope'; @@ -640,7 +641,7 @@ onBeforeUnmount(() => { 등급 {{ gradeLabel }} - {{ account.roles.join(', ') || '-' }} + 가입일시 diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 471b537e..973ae03f 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -7,6 +7,7 @@ import { import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common/gateway/profileStatus'; import { computed, onMounted, ref, watch } from 'vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue'; +import RoleSummary from '../components/RoleSummary.vue'; import ProfileRuntimeDiagnostics from '../components/ProfileRuntimeDiagnostics.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import { useToast } from '../composables/useToast'; @@ -603,6 +604,18 @@ const hasCapability = (permission: string, profileName?: string): boolean => }); const selectedCapability = ref(''); const capabilityProfile = ref(''); +const selectedCapabilityDefinition = computed(() => + capabilities.value.find((entry) => entry.permission === selectedCapability.value) +); +const canSelectAllServers = computed(() => selectedCapabilityDefinition.value?.scopes?.includes('*') ?? false); +const capabilityProfiles = computed(() => + kakaoPolicies.value.filter((policy) => + selectedCapabilityDefinition.value?.scopes?.some((scope) => scope === '*' || scope === policy.profileName) + ) +); +watch(selectedCapability, () => { + capabilityProfile.value = ''; +}); const userActionReason = ref(''); const banUntil = ref(''); @@ -1160,6 +1173,13 @@ const applyCapabilitySelection = () => { rolesStatus.value = '대상 서버를 선택하세요.'; return; } + if ( + capability.scope === 'PROFILE' && + !capability.scopes?.some((scope) => scope === '*' || scope === capabilityProfile.value) + ) { + rolesStatus.value = '부여 가능한 서버 범위를 확인하세요.'; + return; + } rolesInput.value = capability.scope === 'PROFILE' ? `${capability.permission}:${capabilityProfile.value.trim()}` @@ -1574,7 +1594,7 @@ onMounted(() => {
-
+
@@ -1700,7 +1720,7 @@ onMounted(() => {
{{ userResult.id }}
표시명: {{ userResult.displayName }}
-
권한: {{ userResult.roles.join(', ') || '-' }}
+
권한:
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
@@ -1913,6 +1933,7 @@ onMounted(() => {