feat: 관리자 행동별 모든 서버 선택과 권한 묶음 표시를 추가한다
This commit is contained in:
1 parent
257a2bea10
commit
dcecb32ef4
6 files changed
+281
-7
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
import { writeFile } from 'node:fs/promises';
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { formatRoleGroups } from '../src/utils/roleDisplay';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
const operationNames = (route: Route): string[] => {
|
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(',');
|
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 }> = [];
|
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 deleteAfter: string | null = null;
|
||||||
let graceUntil: string | null = null;
|
let graceUntil: string | null = null;
|
||||||
let username = 'target';
|
let username = 'target';
|
||||||
@@ -59,6 +66,7 @@ const installFixture = async (page: Page) => {
|
|||||||
description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.',
|
description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.',
|
||||||
risk: 'HIGH',
|
risk: 'HIGH',
|
||||||
scope: 'PROFILE',
|
scope: 'PROFILE',
|
||||||
|
scopes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
permission: 'admin.audit.read',
|
permission: 'admin.audit.read',
|
||||||
@@ -70,6 +78,15 @@ const installFixture = async (page: Page) => {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (operation === 'admin.audit.list') return response(auditHistory);
|
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.getLocalAccountStatus') return response({ enabled: false });
|
||||||
if (operation === 'admin.users.list') {
|
if (operation === 'admin.users.list') {
|
||||||
return response({
|
return response({
|
||||||
@@ -111,12 +128,20 @@ const installFixture = async (page: Page) => {
|
|||||||
if (operation === 'admin.profiles.listNavigation') return response([]);
|
if (operation === 'admin.profiles.listNavigation') return response([]);
|
||||||
if (operation === 'admin.profiles.list') return response([]);
|
if (operation === 'admin.profiles.list') return response([]);
|
||||||
if (operation === 'admin.profiles.listScenarios') return response([]);
|
if (operation === 'admin.profiles.listScenarios') return response([]);
|
||||||
|
if (operation === 'admin.users.updateRoles') {
|
||||||
|
const input = (body as Record<string, { roles: string[]; mode: string }>)['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') {
|
if (operation === 'admin.users.lookup') {
|
||||||
return response({
|
return response({
|
||||||
id: 'target-user',
|
id: 'target-user',
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
roles: ['user'],
|
roles,
|
||||||
sanctions: {},
|
sanctions: {},
|
||||||
oauthType: 'KAKAO',
|
oauthType: 'KAKAO',
|
||||||
oauthId: 'fixture-kakao-stable-id',
|
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 });
|
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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { formatRoleGroups } from '../utils/roleDisplay';
|
||||||
|
|
||||||
|
const props = defineProps<{ roles: readonly string[] }>();
|
||||||
|
const groups = computed(() => formatRoleGroups(props.roles));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="role-summary" aria-label="보유 권한">
|
||||||
|
<template v-for="(group, index) in groups" :key="index">
|
||||||
|
<span v-if="index">, </span><span class="role-group">{{ group }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="!groups.length">-</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.role-summary {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.role-group {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/** 표시만 묶는다. 명시적인 서버 권한을 모든 서버 권한으로 승격하지 않는다. */
|
||||||
|
export const formatRoleGroups = (roles: readonly string[]): string[] => {
|
||||||
|
const groups = new Map<string, { permission: string; instance: string; profiles: string[] }>();
|
||||||
|
const entries: Array<string | { permission: string; instance: string; profiles: string[] }> = [];
|
||||||
|
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}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router';
|
|||||||
|
|
||||||
import { useToast } from '../composables/useToast';
|
import { useToast } from '../composables/useToast';
|
||||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||||
|
import RoleSummary from '../components/RoleSummary.vue';
|
||||||
import { createGameTrpc } from '../utils/gameTrpc';
|
import { createGameTrpc } from '../utils/gameTrpc';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { sealPassword } from '../utils/passwordEnvelope';
|
import { sealPassword } from '../utils/passwordEnvelope';
|
||||||
@@ -640,7 +641,7 @@ onBeforeUnmount(() => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="legacy-bg1">등급</th>
|
<th class="legacy-bg1">등급</th>
|
||||||
<td colspan="2">{{ gradeLabel }}</td>
|
<td colspan="2">{{ gradeLabel }}</td>
|
||||||
<td colspan="3">{{ account.roles.join(', ') || '-' }}</td>
|
<td colspan="3"><RoleSummary :roles="account.roles" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="legacy-bg1">가입일시</th>
|
<th class="legacy-bg1">가입일시</th>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common/gateway/profileStatus';
|
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common/gateway/profileStatus';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||||
|
import RoleSummary from '../components/RoleSummary.vue';
|
||||||
import ProfileRuntimeDiagnostics from '../components/ProfileRuntimeDiagnostics.vue';
|
import ProfileRuntimeDiagnostics from '../components/ProfileRuntimeDiagnostics.vue';
|
||||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||||
import { useToast } from '../composables/useToast';
|
import { useToast } from '../composables/useToast';
|
||||||
@@ -603,6 +604,18 @@ const hasCapability = (permission: string, profileName?: string): boolean =>
|
|||||||
});
|
});
|
||||||
const selectedCapability = ref('');
|
const selectedCapability = ref('');
|
||||||
const capabilityProfile = 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 userActionReason = ref('');
|
||||||
|
|
||||||
const banUntil = ref('');
|
const banUntil = ref('');
|
||||||
@@ -1160,6 +1173,13 @@ const applyCapabilitySelection = () => {
|
|||||||
rolesStatus.value = '대상 서버를 선택하세요.';
|
rolesStatus.value = '대상 서버를 선택하세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
capability.scope === 'PROFILE' &&
|
||||||
|
!capability.scopes?.some((scope) => scope === '*' || scope === capabilityProfile.value)
|
||||||
|
) {
|
||||||
|
rolesStatus.value = '부여 가능한 서버 범위를 확인하세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
rolesInput.value =
|
rolesInput.value =
|
||||||
capability.scope === 'PROFILE'
|
capability.scope === 'PROFILE'
|
||||||
? `${capability.permission}:${capabilityProfile.value.trim()}`
|
? `${capability.permission}:${capabilityProfile.value.trim()}`
|
||||||
@@ -1574,7 +1594,7 @@ onMounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<section v-if="section === 'users'" class="grid min-w-0 items-start gap-6 xl:grid-cols-2">
|
<section v-if="section === 'users'" class="grid min-w-0 grid-cols-1 items-start gap-6 xl:grid-cols-2">
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2">
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2">
|
||||||
<div class="flex flex-wrap items-end justify-between gap-2">
|
<div class="flex flex-wrap items-end justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -1700,7 +1720,7 @@ onMounted(() => {
|
|||||||
<div class="text-xs text-zinc-500">{{ userResult.id }}</div>
|
<div class="text-xs text-zinc-500">{{ userResult.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-400">표시명: {{ userResult.displayName }}</div>
|
<div class="text-xs text-zinc-400">표시명: {{ userResult.displayName }}</div>
|
||||||
<div class="text-xs text-zinc-400">권한: {{ userResult.roles.join(', ') || '-' }}</div>
|
<div class="text-xs text-zinc-400">권한: <RoleSummary :roles="userResult.roles" /></div>
|
||||||
<div class="text-xs text-zinc-500">
|
<div class="text-xs text-zinc-500">
|
||||||
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
|
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -1913,6 +1933,7 @@ onMounted(() => {
|
|||||||
<div class="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
|
<div class="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
|
||||||
<select
|
<select
|
||||||
v-model="selectedCapability"
|
v-model="selectedCapability"
|
||||||
|
aria-label="관리 기능 권한"
|
||||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
:disabled="!hasUser"
|
:disabled="!hasUser"
|
||||||
>
|
>
|
||||||
@@ -1925,14 +1946,16 @@ onMounted(() => {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
|
v-if="selectedCapabilityDefinition?.scope === 'PROFILE'"
|
||||||
v-model="capabilityProfile"
|
v-model="capabilityProfile"
|
||||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
:disabled="!hasUser || !kakaoPolicies.length"
|
:disabled="!hasUser"
|
||||||
aria-label="권한 대상 서버"
|
aria-label="권한 대상 서버"
|
||||||
>
|
>
|
||||||
<option value="">대상 서버 선택</option>
|
<option value="">대상 서버 선택</option>
|
||||||
|
<option v-if="canSelectAllServers" value="*">모든 서버</option>
|
||||||
<option
|
<option
|
||||||
v-for="policy in kakaoPolicies"
|
v-for="policy in capabilityProfiles"
|
||||||
:key="policy.profileName"
|
:key="policy.profileName"
|
||||||
:value="policy.profileName"
|
:value="policy.profileName"
|
||||||
>
|
>
|
||||||
@@ -1947,6 +1970,10 @@ onMounted(() => {
|
|||||||
선택 반영
|
선택 반영
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="capabilityProfile === '*'" class="text-xs text-amber-300">
|
||||||
|
선택한 행동만 현재와 앞으로 추가되는 모든 서버에 허용합니다. 계정 조사 등 다른 권한은
|
||||||
|
포함하지 않습니다.
|
||||||
|
</p>
|
||||||
<div v-if="selectedCapability" class="text-xs text-zinc-500">
|
<div v-if="selectedCapability" class="text-xs text-zinc-500">
|
||||||
{{ capabilities.find((item) => item.permission === selectedCapability)?.description }}
|
{{ capabilities.find((item) => item.permission === selectedCapability)?.description }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ capability로 분리되며 포괄 운영 권한은 사용하지 않습니다. Ga
|
|||||||
profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다.
|
profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다.
|
||||||
일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다.
|
일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다.
|
||||||
|
|
||||||
|
사용자 관리의 `접근 · 권한`에서 PROFILE 기능은 개별 서버 또는 `모든 서버`를
|
||||||
|
선택합니다. 모든 서버는 기존 `<permission>:*` 역할로 저장하며, 현재 및 앞으로
|
||||||
|
추가되는 서버에 해당 행동만 허용합니다. 기능 목록의 `scopes`에 `*`가 있는
|
||||||
|
관리자만 전체 범위를 선택할 수 있고, 서버별 관리자에게는 보유 범위만 표시합니다.
|
||||||
|
실제 변경에는 `admin.users.manage`와 변경할 역할에 대한 기존 검사가 모두 적용됩니다.
|
||||||
|
예를 들어 `admin.playAudit.read:*`는 플레이 감사 조회만 허용하며,
|
||||||
|
계정 조사에는 별도 전역 `admin.playAudit.accounts`가 필요합니다.
|
||||||
|
|
||||||
|
계정과 관리자 계정 상세의 권한 표시는 같은 행동·instance의 명시적 서버 목록을
|
||||||
|
`admin.profiles.runtime:{che, kwe, pwe}:default`처럼 묶습니다. 이 표기는 읽기용이며
|
||||||
|
저장 역할과 API 입력은 기존 문자열을 유지합니다. 명시적인 모든 현 서버 목록도
|
||||||
|
자동으로 `*`로 바꾸지 않습니다. instance가 다르면 별도로 표시합니다.
|
||||||
|
|
||||||
### 일괄 업데이트
|
### 일괄 업데이트
|
||||||
|
|
||||||
일괄 업데이트는 새 종류의 배포 엔진이 아니라 기존 Gateway release와 profile
|
일괄 업데이트는 새 종류의 배포 엔진이 아니라 기존 Gateway release와 profile
|
||||||
|
|||||||
Reference in new issue
Block a user