fix(frontend): NPC 정책 실행 버튼 권한을 재검사한다

조회 전용 사용자는 정책 초안을 계속 편집할 수 있게 유지하고, 수정 권한이 없으면 초깃값·이전값·설정 버튼을 DOM과 핸들러 양쪽에서 차단한다. Chromium 회귀에서 disabled 상태와 초안 보존을 확인한다.
This commit is contained in:
2026-08-24 08:54:57 +00:00
parent b2b9186148
commit 5088daa396
2 changed files with 82 additions and 19 deletions
+39 -6
View File
@@ -375,19 +375,52 @@ test('physical mobile touch reorders NPC priority across active and inactive lis
}
});
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
test('a read-level user may edit drafts but cannot reset, revert, or submit them', async ({ page }) => {
const state: FixtureState = { permissionLevel: 1, mutations: [] };
await installFixture(page, state);
await gotoPolicy(page);
const input = page.getByLabel('국가 권장 금');
await expect(input).toBeEnabled();
await input.fill('23456');
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
const nationPanel = page.locator('.priority-panel').first();
const activePriority = nationPanel.locator('.priority-column').nth(1).getByText('불가침제의');
const inactivePriorityList = nationPanel.locator('.priority-column').first().locator('.priority-list');
await activePriority.dragTo(inactivePriorityList);
await expect(inactivePriorityList.getByText('불가침제의')).toBeVisible();
const actionButtons = page.locator('.control_bar button');
await expect(actionButtons).toHaveCount(9);
for (const button of await actionButtons.all()) {
await expect(button).toBeDisabled();
}
const disabledStyle = await actionButtons.first().evaluate((element) => {
const style = getComputedStyle(element);
return { cursor: style.cursor, opacity: style.opacity, filter: style.filter };
});
expect(disabledStyle).toEqual({ cursor: 'not-allowed', opacity: '0.55', filter: 'none' });
// The handler guard is independent of the disabled DOM attribute. This also
// protects callers that dispatch a click after permission data has changed.
for (const button of await page.locator('#container > .control_bar button').all()) {
await button.evaluate((element) => {
element.removeAttribute('disabled');
(element as HTMLButtonElement).click();
});
}
for (const button of await nationPanel.locator('.control_bar button').all()) {
await button.evaluate((element) => {
element.removeAttribute('disabled');
(element as HTMLButtonElement).click();
});
}
await expect(input).toHaveValue('23456');
expect(state.mutations).toEqual(['npc.setNationPolicy']);
await expect(inactivePriorityList.getByText('불가침제의')).toBeVisible();
expect(state.mutations).toEqual([]);
await screenshot(page, 'core-npc-policy-read-only-controls.png');
});
test('a user below secret read permission receives a recoverable page error', async ({ page }) => {
+43 -13
View File
@@ -46,6 +46,7 @@ const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]);
const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useGameFeedback();
const canManagePolicy = computed(() => (data.value?.permissionLevel ?? -1) >= 3);
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) return value.message;
@@ -282,19 +283,19 @@ const priorityPanels = computed<PriorityPanel[]>(() => {
});
const resetPolicy = () => {
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.');
};
const rollbackPolicy = () => {
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (!canManagePolicy.value || !lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(lastSavedPolicy.value);
showInfoToast('이전 설정으로 되돌렸습니다.');
};
const submitPolicy = async () => {
if (!policyDraft.value || !window.confirm('저장할까요?')) return;
if (!canManagePolicy.value || !policyDraft.value || !window.confirm('저장할까요?')) return;
try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
lastSavedPolicy.value = clonePolicy(policyDraft.value);
@@ -305,7 +306,7 @@ const submitPolicy = async () => {
};
const resetPriority = (section: PrioritySectionKey) => {
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(
data.value.defaultNationPriority,
@@ -321,7 +322,7 @@ const resetPriority = (section: PrioritySectionKey) => {
};
const rollbackPriority = (section: PrioritySectionKey) => {
if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (!canManagePolicy.value || !data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(
lastSavedNationPriority.value,
@@ -338,7 +339,7 @@ const rollbackPriority = (section: PrioritySectionKey) => {
const submitPriority = async (section: PrioritySectionKey) => {
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!state || !window.confirm('저장할까요?')) return;
if (!canManagePolicy.value || !state || !window.confirm('저장할까요?')) return;
try {
if (section === 'nation') {
await trpc.npc.setNationPriority.mutate(state.active);
@@ -416,10 +417,16 @@ const submitPriority = async (section: PrioritySectionKey) => {
<div class="control_bar">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button>
<button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button>
<button class="reset_btn" type="button" :disabled="!canManagePolicy" @click="resetPolicy">
초깃값으로
</button>
<button class="revert_btn" type="button" :disabled="!canManagePolicy" @click="rollbackPolicy">
이전값으로
</button>
</div>
<button class="submit_btn" type="button" @click="submitPolicy">설정</button>
<button class="submit_btn" type="button" :disabled="!canManagePolicy" @click="submitPolicy">
설정
</button>
</div>
<div class="priority-sections">
@@ -498,14 +505,31 @@ const submitPriority = async (section: PrioritySectionKey) => {
</div>
<div class="control_bar priority-control">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPriority(panel.key)">
<button
class="reset_btn"
type="button"
:disabled="!canManagePolicy"
@click="resetPriority(panel.key)"
>
초깃값으로
</button>
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)">
<button
class="revert_btn"
type="button"
:disabled="!canManagePolicy"
@click="rollbackPriority(panel.key)"
>
이전값으로
</button>
</div>
<button class="submit_btn" type="button" @click="submitPriority(panel.key)">설정</button>
<button
class="submit_btn"
type="button"
:disabled="!canManagePolicy"
@click="submitPriority(panel.key)"
>
설정
</button>
</div>
</section>
</div>
@@ -728,10 +752,16 @@ const submitPriority = async (section: PrioritySectionKey) => {
border-radius: 4px;
}
.control_bar button:hover {
.control_bar button:not(:disabled):hover {
filter: brightness(1.15);
}
.control_bar button:disabled {
cursor: not-allowed;
opacity: 0.55;
filter: none;
}
.control_bar button:focus-visible,
.help-button:focus-visible {
outline: 2px solid #fff;