feat: 관리자가 사유를 남겨 탈퇴 예약을 취소하도록 추가한다
This commit is contained in:
1 parent
dcecb32ef4
commit
d1cdf8a85f
5 files changed
+366
-14
No files matched your search
@@ -1161,6 +1161,31 @@ export const adminRouter = router({
|
|||||||
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-scheduled-withdrawal');
|
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-scheduled-withdrawal');
|
||||||
return { ok: true, deleteAfter: deleteAfter.toISOString() };
|
return { ok: true, deleteAfter: deleteAfter.toISOString() };
|
||||||
}),
|
}),
|
||||||
|
cancelDeletion: userAdminProcedure
|
||||||
|
.input(z.object({ userId: z.string().min(1), reason: z.string().trim().min(3).max(200) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = await ctx.users.findById(input.userId);
|
||||||
|
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
|
||||||
|
assertTargetUserManageable(requireAdminAuth(ctx), user);
|
||||||
|
if (!user.deleteAfter) {
|
||||||
|
throw new TRPCError({ code: 'CONFLICT', message: '탈퇴 예약이 없는 계정입니다.' });
|
||||||
|
}
|
||||||
|
// 관리자 복구는 보존 기한과 무관하게 허용하되, 조회한 예약만 해제한다.
|
||||||
|
// revision 증가로 이전 session과 사용자 복구 증명의 재사용을 막는다.
|
||||||
|
if (!(await ctx.users.cancelDeletion(user.id, user.deleteAfter, user.authRevision ?? 0))) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: '계정 상태가 변경되었습니다. 다시 조회해 주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let flushPublished = true;
|
||||||
|
try {
|
||||||
|
await ctx.flushPublisher.publishUserFlush(user.id, 'admin-cancelled-withdrawal');
|
||||||
|
} catch {
|
||||||
|
flushPublished = false;
|
||||||
|
}
|
||||||
|
return { ok: true, deleteAfter: null, flushPublished };
|
||||||
|
}),
|
||||||
forceDelete: userAdminProcedure
|
forceDelete: userAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ const buildCaller = async (
|
|||||||
createOperation: GatewayProfileRepository['createOperation'],
|
createOperation: GatewayProfileRepository['createOperation'],
|
||||||
options: {
|
options: {
|
||||||
adminRoles?: string[];
|
adminRoles?: string[];
|
||||||
|
flushError?: Error;
|
||||||
firstUserIsAdmin?: boolean;
|
firstUserIsAdmin?: boolean;
|
||||||
runtimeActionCreateError?: unknown;
|
runtimeActionCreateError?: unknown;
|
||||||
initialNotice?: string;
|
initialNotice?: string;
|
||||||
@@ -242,6 +243,7 @@ const buildCaller = async (
|
|||||||
sessions,
|
sessions,
|
||||||
flushPublisher: {
|
flushPublisher: {
|
||||||
publishUserFlush: async (userId, reason, metadata) => {
|
publishUserFlush: async (userId, reason, metadata) => {
|
||||||
|
if (options.flushError) throw options.flushError;
|
||||||
flushes.push({
|
flushes.push({
|
||||||
userId,
|
userId,
|
||||||
reason,
|
reason,
|
||||||
@@ -2216,6 +2218,94 @@ describe('Gateway administrator account controls', () => {
|
|||||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([new Date('2020-01-01T00:00:00Z'), new Date('2099-01-01T00:00:00Z')])(
|
||||||
|
'lets a user administrator cancel deletion dated %s with an audited reason',
|
||||||
|
async (deadline) => {
|
||||||
|
const harness = await buildCaller(unusedCreateOperation, {
|
||||||
|
adminRoles: ['user', 'admin.users.manage'],
|
||||||
|
firstUserIsAdmin: false,
|
||||||
|
});
|
||||||
|
const target = await harness.users.createUser({ username: 'restore-target', password: 'secretpass' });
|
||||||
|
await harness.users.updateSanctions(target.id, { bannedUntil: '2099-01-01T00:00:00Z' });
|
||||||
|
await harness.users.scheduleDeletion(target.id, deadline);
|
||||||
|
const before = structuredClone(await harness.users.findById(target.id));
|
||||||
|
expect(
|
||||||
|
await harness.caller.admin.users.cancelDeletion({
|
||||||
|
userId: target.id,
|
||||||
|
reason: '고객 본인 확인 후 탈퇴 취소',
|
||||||
|
})
|
||||||
|
).toEqual({ ok: true, deleteAfter: null, flushPublished: true });
|
||||||
|
const after = await harness.users.findById(target.id);
|
||||||
|
expect(after?.deleteAfter).toBeUndefined();
|
||||||
|
expect(after?.authRevision).toBe((before?.authRevision ?? 0) + 1);
|
||||||
|
expect(after?.sanctions).toEqual(before?.sanctions);
|
||||||
|
expect(after?.passwordHash).toBe(before?.passwordHash);
|
||||||
|
expect(after?.oauthId).toBe(before?.oauthId);
|
||||||
|
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-cancelled-withdrawal' });
|
||||||
|
expect(harness.auditEvents.map((event) => event.outcome)).toEqual(['STARTED', 'SUCCEEDED']);
|
||||||
|
expect(harness.auditEvents[1]).toMatchObject({
|
||||||
|
action: 'admin.users.cancelDeletion',
|
||||||
|
capability: 'admin.users.manage',
|
||||||
|
actorUserId: harness.admin.id,
|
||||||
|
targetId: target.id,
|
||||||
|
reason: '고객 본인 확인 후 탈퇴 취소',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
harness.caller.admin.users.cancelDeletion({ userId: target.id, reason: '중복 취소 요청' })
|
||||||
|
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||||
|
expect(harness.auditEvents.at(-1)).toMatchObject({ outcome: 'FAILED', errorCode: 'CONFLICT' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('denies missing scope, protected root targets, bad reasons and missing targets without mutation', async () => {
|
||||||
|
for (const roles of [['admin.notice.manage'], ['admin.users.manage']]) {
|
||||||
|
const h = await buildCaller(unusedCreateOperation, { adminRoles: roles, firstUserIsAdmin: false });
|
||||||
|
const target = await h.users.createUser({ username: 'protected-restore', password: 'secretpass' });
|
||||||
|
await h.users.updateRoles(target.id, ['superuser']);
|
||||||
|
await h.users.scheduleDeletion(target.id, new Date('2020-01-01'));
|
||||||
|
const cancel = vi.spyOn(h.users, 'cancelDeletion');
|
||||||
|
await expect(
|
||||||
|
h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '권한 없는 복구 시도' })
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
expect(h.flushes).toHaveLength(0);
|
||||||
|
expect(h.auditEvents.at(-1)).toMatchObject({ outcome: 'FAILED', errorCode: 'FORBIDDEN' });
|
||||||
|
}
|
||||||
|
const h = await buildCaller(unusedCreateOperation);
|
||||||
|
await expect(h.caller.admin.users.cancelDeletion({ userId: h.admin.id, reason: ' ' })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
h.caller.admin.users.cancelDeletion({ userId: 'missing-user', reason: '없는 계정 복구' })
|
||||||
|
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a newer deletion reservation when compare-and-swap fails', async () => {
|
||||||
|
const h = await buildCaller(unusedCreateOperation);
|
||||||
|
const target = await h.users.createUser({ username: 'rescheduled-restore', password: 'secretpass' });
|
||||||
|
await h.users.scheduleDeletion(target.id, new Date('2020-01-01'));
|
||||||
|
vi.spyOn(h.users, 'cancelDeletion').mockImplementation(async () => {
|
||||||
|
await h.users.scheduleDeletion(target.id, new Date('2099-01-01'));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '재예약 충돌 확인' })
|
||||||
|
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||||
|
expect((await h.users.findById(target.id))?.deleteAfter).toBe('2099-01-01T00:00:00.000Z');
|
||||||
|
expect(h.flushes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a committed cancellation as success when flush delivery fails', async () => {
|
||||||
|
const h = await buildCaller(unusedCreateOperation, { flushError: new Error('synthetic flush failure') });
|
||||||
|
const target = await h.users.createUser({ username: 'flush-restore', password: 'secretpass' });
|
||||||
|
await h.users.scheduleDeletion(target.id, new Date('2020-01-01'));
|
||||||
|
expect(
|
||||||
|
await h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '고객 요청 복구' })
|
||||||
|
).toMatchObject({ ok: true, flushPublished: false });
|
||||||
|
expect((await h.users.findById(target.id))?.deleteAfter).toBeUndefined();
|
||||||
|
expect(h.auditEvents.at(-1)).toMatchObject({ outcome: 'SUCCEEDED' });
|
||||||
|
});
|
||||||
|
|
||||||
it('prevents a delegated administrator from changing a root administrator', async () => {
|
it('prevents a delegated administrator from changing a root administrator', async () => {
|
||||||
const harness = await buildCaller(unusedCreateOperation, {
|
const harness = await buildCaller(unusedCreateOperation, {
|
||||||
adminRoles: ['user', 'admin.users.manage'],
|
adminRoles: ['user', 'admin.users.manage'],
|
||||||
|
|||||||
@@ -505,4 +505,46 @@ describe('admin security over HTTP transport', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('requires a valid authorized administrator to cancel deletion over HTTP and invalidates prior sessions', async () => {
|
||||||
|
const h = await createHarness();
|
||||||
|
await h.users.scheduleDeletion(h.target.id, new Date('2020-01-01'));
|
||||||
|
const input = { userId: h.target.id, reason: '본인 확인 후 관리자 복구' };
|
||||||
|
expect((await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input)).response.status).toBe(401);
|
||||||
|
expect(
|
||||||
|
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.targetSessionToken)).response.status
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', { ...input, reason: ' ' }, h.adminSessionToken))
|
||||||
|
.response.status
|
||||||
|
).toBe(400);
|
||||||
|
await h.users.updateRoles(h.target.id, ['superuser']);
|
||||||
|
expect(
|
||||||
|
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status
|
||||||
|
).toBe(403);
|
||||||
|
expect(h.flushes).toHaveLength(0);
|
||||||
|
expect((await h.users.findById(h.target.id))?.deleteAfter).toBeDefined();
|
||||||
|
await h.users.updateRoles(h.target.id, ['user']);
|
||||||
|
expect(
|
||||||
|
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status
|
||||||
|
).toBe(200);
|
||||||
|
expect((await h.users.findById(h.target.id))?.deleteAfter).toBeUndefined();
|
||||||
|
const me = await fetch(`${h.baseUrl}/trpc/me`, { headers: { 'x-session-token': h.targetSessionToken } });
|
||||||
|
expect(await me.json()).toMatchObject({ result: { data: null } });
|
||||||
|
expect(
|
||||||
|
(await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status
|
||||||
|
).toBe(409);
|
||||||
|
const noScope = await createHarness(['user', 'admin.notice.manage']);
|
||||||
|
await noScope.users.scheduleDeletion(noScope.target.id, new Date('2020-01-01'));
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await postTrpc(
|
||||||
|
noScope.baseUrl,
|
||||||
|
'admin.users.cancelDeletion',
|
||||||
|
{ userId: noScope.target.id, reason: '사용자 관리 권한 없음' },
|
||||||
|
noScope.adminSessionToken
|
||||||
|
)
|
||||||
|
).response.status
|
||||||
|
).toBe(403);
|
||||||
|
expect((await noScope.users.findById(noScope.target.id))?.deleteAfter).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,16 @@ 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, scopes = ['*']) => {
|
const installFixture = async (
|
||||||
|
page: Page,
|
||||||
|
scopes = ['*'],
|
||||||
|
deletion: {
|
||||||
|
deleteAfter?: string;
|
||||||
|
cancelFails?: boolean;
|
||||||
|
flushPublished?: boolean;
|
||||||
|
beforeCancel?: () => Promise<void>;
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
const requests: Array<{ operation: string; body: unknown }> = [];
|
const requests: Array<{ operation: string; body: unknown }> = [];
|
||||||
let roles = [
|
let roles = [
|
||||||
'user',
|
'user',
|
||||||
@@ -16,7 +25,7 @@ const installFixture = async (page: Page, scopes = ['*']) => {
|
|||||||
'admin.profiles.runtime:kwe:default',
|
'admin.profiles.runtime:kwe:default',
|
||||||
'admin.profiles.runtime:pwe:default',
|
'admin.profiles.runtime:pwe:default',
|
||||||
];
|
];
|
||||||
let deleteAfter: string | null = null;
|
let deleteAfter: string | null = deletion.deleteAfter ?? null;
|
||||||
let graceUntil: string | null = null;
|
let graceUntil: string | null = null;
|
||||||
let username = 'target';
|
let username = 'target';
|
||||||
let displayName = '대상 사용자';
|
let displayName = '대상 사용자';
|
||||||
@@ -40,8 +49,9 @@ const installFixture = async (page: Page, scopes = ['*']) => {
|
|||||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
const operations = operationNames(route);
|
const operations = operationNames(route);
|
||||||
const body = route.request().postDataJSON() as unknown;
|
const body = route.request().postDataJSON() as unknown;
|
||||||
|
for (const operation of operations) requests.push({ operation, body });
|
||||||
|
if (operations.includes('admin.users.cancelDeletion')) await deletion.beforeCancel?.();
|
||||||
const results = operations.map((operation) => {
|
const results = operations.map((operation) => {
|
||||||
requests.push({ operation, body });
|
|
||||||
if (operation === 'me') {
|
if (operation === 'me') {
|
||||||
return response({
|
return response({
|
||||||
id: 'admin-user',
|
id: 'admin-user',
|
||||||
@@ -247,6 +257,25 @@ const installFixture = async (page: Page, scopes = ['*']) => {
|
|||||||
});
|
});
|
||||||
return response({ ok: true, deleteAfter });
|
return response({ ok: true, deleteAfter });
|
||||||
}
|
}
|
||||||
|
if (operation === 'admin.users.cancelDeletion') {
|
||||||
|
if (deletion.cancelFails)
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: '계정 상태가 변경되었습니다. 다시 조회해 주세요.',
|
||||||
|
code: -32009,
|
||||||
|
data: { code: 'CONFLICT', httpStatus: 409 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
deleteAfter = null;
|
||||||
|
const input = (body as Record<string, { reason: string }>)['0'];
|
||||||
|
auditHistory.unshift({
|
||||||
|
...auditHistory[0],
|
||||||
|
id: 'audit-cancel-deletion',
|
||||||
|
action: 'admin.users.cancelDeletion',
|
||||||
|
reason: input.reason,
|
||||||
|
});
|
||||||
|
return response({ ok: true, deleteAfter: null, flushPublished: deletion.flushPublished ?? true });
|
||||||
|
}
|
||||||
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||||
});
|
});
|
||||||
const isBatch = new URL(route.request().url()).searchParams.get('batch') === '1';
|
const isBatch = new URL(route.request().url()).searchParams.get('batch') === '1';
|
||||||
@@ -509,3 +538,121 @@ test('shows grouped roles on the account page after direct navigation and refres
|
|||||||
await writeFile(testInfo.outputPath('account-permissions.html'), await page.content());
|
await writeFile(testInfo.outputPath('account-permissions.html'), await page.content());
|
||||||
await page.screenshot({ path: testInfo.outputPath('account-permissions.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('account-permissions.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
|
{ name: 'mobile', width: 390, height: 844 },
|
||||||
|
]) {
|
||||||
|
test(`cancels an expired deletion with a reason, confirmation and audit on ${viewport.name}`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
let complete: (() => void) | undefined;
|
||||||
|
const requests = await installFixture(page, ['*'], {
|
||||||
|
deleteAfter: '2020-01-01T00:00:00Z',
|
||||||
|
beforeCancel: () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
complete = resolve;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await page.goto('admin/users');
|
||||||
|
const target = page.getByRole('button', { name: /target.*대상 사용자/ });
|
||||||
|
await target.click();
|
||||||
|
await page.getByRole('button', { name: /탈퇴 · 이력/ }).click();
|
||||||
|
const panel = page.getByRole('heading', { name: '관리자 탈퇴 예약', exact: true }).locator('..');
|
||||||
|
const cancel = panel.getByRole('button', { name: '탈퇴 취소', exact: true });
|
||||||
|
await expect(cancel).toBeEnabled();
|
||||||
|
await cancel.click();
|
||||||
|
expect(requests.filter(({ operation }) => operation === 'admin.users.cancelDeletion')).toHaveLength(0);
|
||||||
|
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 후 탈퇴 취소');
|
||||||
|
page.once('dialog', (dialog) => dialog.dismiss());
|
||||||
|
await cancel.click();
|
||||||
|
expect(requests.filter(({ operation }) => operation === 'admin.users.cancelDeletion')).toHaveLength(0);
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await cancel.hover();
|
||||||
|
await expect
|
||||||
|
.poll(() => cancel.evaluate((el) => getComputedStyle(el).backgroundColor))
|
||||||
|
.toBe('oklch(0.546 0.245 262.881)');
|
||||||
|
await page.mouse.down();
|
||||||
|
expect(await cancel.evaluate((el) => el.matches(':active'))).toBe(true);
|
||||||
|
await page.mouse.move(1, 1);
|
||||||
|
await page.mouse.up();
|
||||||
|
await cancel.focus();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Shift+Tab');
|
||||||
|
await expect(cancel).toBeFocused();
|
||||||
|
const geometry = await panel.evaluate((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const button = el.querySelectorAll('button')[1]!;
|
||||||
|
const buttonRect = button.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(button);
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
viewport: innerWidth,
|
||||||
|
right: rect.right,
|
||||||
|
buttonWidth: buttonRect.width,
|
||||||
|
buttonHeight: buttonRect.height,
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
focus: button.matches(':focus-visible'),
|
||||||
|
html: el.outerHTML,
|
||||||
|
documentOverflow: document.documentElement.scrollWidth - innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.documentOverflow).toBe(0);
|
||||||
|
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
|
||||||
|
expect(geometry.buttonHeight).toBeGreaterThanOrEqual(40);
|
||||||
|
expect(geometry.focus).toBe(true);
|
||||||
|
await writeFile(testInfo.outputPath('admin-deletion-geometry-style.json'), JSON.stringify(geometry, null, 2));
|
||||||
|
await writeFile(testInfo.outputPath('admin-deletion-panel.html'), geometry.html);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('admin-deletion-before.png'), fullPage: true });
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await cancel.click();
|
||||||
|
await expect(cancel).toBeDisabled();
|
||||||
|
await expect(panel.getByRole('button', { name: '보존 기간 후 탈퇴 예약' })).toBeDisabled();
|
||||||
|
await expect
|
||||||
|
.poll(() => requests.filter(({ operation }) => operation === 'admin.users.cancelDeletion').length)
|
||||||
|
.toBe(1);
|
||||||
|
expect(complete).toBeDefined();
|
||||||
|
complete!();
|
||||||
|
await expect(panel.getByRole('status')).toHaveText('탈퇴 예약을 취소했습니다.');
|
||||||
|
await expect(cancel).toBeDisabled();
|
||||||
|
await expect(target).not.toContainText('탈퇴 예약');
|
||||||
|
await expect(page.getByText('SUCCEEDED · admin.users.cancelDeletion').first()).toBeVisible();
|
||||||
|
const request = requests.find(({ operation }) => operation === 'admin.users.cancelDeletion');
|
||||||
|
expect(Object.values(request!.body as Record<string, unknown>)).toEqual([
|
||||||
|
{ userId: 'target-user', reason: '본인 확인 후 탈퇴 취소' },
|
||||||
|
]);
|
||||||
|
await page.reload();
|
||||||
|
await target.click();
|
||||||
|
await page.getByRole('button', { name: /탈퇴 · 이력/ }).click();
|
||||||
|
await expect(cancel).toBeDisabled();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('admin-deletion-after.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps the pending deletion visible on failure and reports delayed flush as committed success', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const requests = await installFixture(page, ['*'], { deleteAfter: '2020-01-01T00:00:00Z', cancelFails: true });
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.goto('admin/users');
|
||||||
|
await page.getByRole('button', { name: /target.*대상 사용자/ }).click();
|
||||||
|
await page.getByRole('button', { name: /탈퇴 · 이력/ }).click();
|
||||||
|
const panel = page.getByRole('heading', { name: '관리자 탈퇴 예약', exact: true }).locator('..');
|
||||||
|
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 후 복구');
|
||||||
|
await panel.getByRole('button', { name: '탈퇴 취소', exact: true }).click();
|
||||||
|
await expect(panel.getByRole('status')).toContainText('다시 조회해 주세요.');
|
||||||
|
await expect(panel.getByRole('button', { name: '탈퇴 취소', exact: true })).toBeEnabled();
|
||||||
|
expect(requests.filter(({ operation }) => operation === 'admin.users.cancelDeletion')).toHaveLength(1);
|
||||||
|
await installFixture(page, ['*'], { deleteAfter: '2020-01-01T00:00:00Z', flushPublished: false });
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /target.*대상 사용자/ }).click();
|
||||||
|
await page.getByRole('button', { name: /탈퇴 · 이력/ }).click();
|
||||||
|
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 후 복구');
|
||||||
|
await panel.getByRole('button', { name: '탈퇴 취소', exact: true }).click();
|
||||||
|
await expect(panel.getByRole('status')).toContainText('탈퇴 예약은 취소되었습니다.');
|
||||||
|
await expect(panel.getByRole('button', { name: '탈퇴 취소', exact: true })).toBeDisabled();
|
||||||
|
});
|
||||||
@@ -367,6 +367,13 @@ type AdminClient = {
|
|||||||
deleteAfter: string;
|
deleteAfter: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
cancelDeletion: {
|
||||||
|
mutate: (input: { userId: string; reason: string }) => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
deleteAfter: null;
|
||||||
|
flushPublished: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
profiles: {
|
profiles: {
|
||||||
list: {
|
list: {
|
||||||
@@ -632,6 +639,7 @@ const restrictionNotes = ref('');
|
|||||||
const restrictionStatus = ref('');
|
const restrictionStatus = ref('');
|
||||||
|
|
||||||
const forceDeleteStatus = ref('');
|
const forceDeleteStatus = ref('');
|
||||||
|
const deletionActionBusy = ref(false);
|
||||||
const deletionRetentionDays = ref(30);
|
const deletionRetentionDays = ref(30);
|
||||||
const kakaoGraceUntil = ref('');
|
const kakaoGraceUntil = ref('');
|
||||||
const kakaoGraceStatus = ref('');
|
const kakaoGraceStatus = ref('');
|
||||||
@@ -1485,7 +1493,7 @@ const clearRestriction = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scheduleDeleteUser = async () => {
|
const scheduleDeleteUser = async () => {
|
||||||
if (!userResult.value) {
|
if (!userResult.value || deletionActionBusy.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reason = requireUserActionReason();
|
const reason = requireUserActionReason();
|
||||||
@@ -1496,6 +1504,7 @@ const scheduleDeleteUser = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
deletionActionBusy.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await adminClient.users.scheduleDeletion.mutate({
|
const result = await adminClient.users.scheduleDeletion.mutate({
|
||||||
userId: userResult.value.id,
|
userId: userResult.value.id,
|
||||||
@@ -1507,6 +1516,33 @@ const scheduleDeleteUser = async () => {
|
|||||||
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
|
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
forceDeleteStatus.value = '탈퇴 예약 실패';
|
forceDeleteStatus.value = '탈퇴 예약 실패';
|
||||||
|
} finally {
|
||||||
|
deletionActionBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDeleteUser = async (): Promise<void> => {
|
||||||
|
const target = userResult.value;
|
||||||
|
if (!target?.deleteAfter || deletionActionBusy.value) return;
|
||||||
|
const reason = requireUserActionReason();
|
||||||
|
if (!reason || !window.confirm('선택한 계정의 탈퇴 예약을 취소하시겠습니까?')) return;
|
||||||
|
deletionActionBusy.value = true;
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.cancelDeletion.mutate({ userId: target.id, reason });
|
||||||
|
if (userResult.value?.id === target.id) {
|
||||||
|
userResult.value = { ...userResult.value, deleteAfter: undefined };
|
||||||
|
forceDeleteStatus.value = result.flushPublished
|
||||||
|
? '탈퇴 예약을 취소했습니다.'
|
||||||
|
: '탈퇴 예약은 취소되었습니다. 게임 접속 정보 갱신이 지연되고 있습니다.';
|
||||||
|
await refreshUserHistory();
|
||||||
|
}
|
||||||
|
await loadUserDirectory();
|
||||||
|
} catch (error) {
|
||||||
|
if (userResult.value?.id === target.id) {
|
||||||
|
forceDeleteStatus.value = error instanceof Error ? error.message : '탈퇴 예약 취소 실패';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
deletionActionBusy.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2292,6 +2328,9 @@ onMounted(() => {
|
|||||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||||
>
|
>
|
||||||
<h4 class="text-base font-semibold text-red-400">관리자 탈퇴 예약</h4>
|
<h4 class="text-base font-semibold text-red-400">관리자 탈퇴 예약</h4>
|
||||||
|
<p v-if="userResult?.deleteAfter" class="text-sm text-zinc-300">
|
||||||
|
삭제 예정일: {{ formatServerDateTime(userResult.deleteAfter) }}
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
v-model.number="deletionRetentionDays"
|
v-model.number="deletionRetentionDays"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -2299,16 +2338,25 @@ onMounted(() => {
|
|||||||
max="90"
|
max="90"
|
||||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
aria-label="탈퇴 전 보존 일수"
|
aria-label="탈퇴 전 보존 일수"
|
||||||
:disabled="!hasUser"
|
:disabled="!hasUser || deletionActionBusy"
|
||||||
/>
|
/>
|
||||||
<button
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
|
<button
|
||||||
:disabled="!hasUser"
|
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
|
||||||
@click="scheduleDeleteUser"
|
:disabled="!hasUser || deletionActionBusy"
|
||||||
>
|
@click="scheduleDeleteUser"
|
||||||
보존 기간 후 탈퇴 예약
|
>
|
||||||
</button>
|
보존 기간 후 탈퇴 예약
|
||||||
<div class="text-xs text-zinc-500">{{ forceDeleteStatus }}</div>
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded disabled:opacity-50"
|
||||||
|
:disabled="!userResult?.deleteAfter || deletionActionBusy"
|
||||||
|
@click="cancelDeleteUser"
|
||||||
|
>
|
||||||
|
탈퇴 취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500" role="status">{{ forceDeleteStatus }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in new issue
Block a user