feat(gateway): add special account access
This commit is contained in:
@@ -11,6 +11,7 @@ const installFixture = async (page: Page) => {
|
||||
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||
let deleteAfter: string | null = null;
|
||||
let graceUntil: string | null = null;
|
||||
let specialGrants: Array<Record<string, unknown>> = [];
|
||||
const auditHistory = [
|
||||
{
|
||||
id: 'audit-1',
|
||||
@@ -89,6 +90,7 @@ const installFixture = async (page: Page) => {
|
||||
kakaoVerified: false,
|
||||
kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z',
|
||||
kakaoGraceUntil: graceUntil,
|
||||
specialAccessGrants: specialGrants,
|
||||
profiles: [
|
||||
{
|
||||
profileName: 'che:default',
|
||||
@@ -99,6 +101,14 @@ const installFixture = async (page: Page) => {
|
||||
graceEndsAt: graceUntil ?? '2026-08-10T00:00:00.000Z',
|
||||
generalCreationGraceDays: 0,
|
||||
accessGraceDays: 7,
|
||||
specialAccess: specialGrants.length
|
||||
? {
|
||||
kind: 'RECOVERY',
|
||||
grantId: '11111111-1111-4111-8111-111111111111',
|
||||
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||
allowsGeneralCreation: true,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -114,6 +124,28 @@ const installFixture = async (page: Page) => {
|
||||
});
|
||||
return response({ kakaoGraceUntil: graceUntil });
|
||||
}
|
||||
if (operation === 'admin.users.grantSpecialAccess') {
|
||||
specialGrants = [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
userId: 'target-user',
|
||||
kind: 'RECOVERY',
|
||||
profiles: ['che'],
|
||||
allowsGeneralCreation: true,
|
||||
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||
reason: '휴대폰 분실 임시 복구',
|
||||
grantedByUserId: 'admin-user',
|
||||
createdAt: '2026-08-08T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
auditHistory.unshift({
|
||||
...auditHistory[0],
|
||||
id: 'audit-special',
|
||||
action: 'admin.users.grantSpecialAccess',
|
||||
reason: '휴대폰 분실 임시 복구',
|
||||
});
|
||||
return response(specialGrants[0]);
|
||||
}
|
||||
if (operation === 'admin.users.scheduleDeletion') {
|
||||
deleteAfter = '2026-09-05T00:00:00.000Z';
|
||||
auditHistory.unshift({
|
||||
@@ -150,7 +182,17 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
.not.toBe(baseDeleteColor);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-hover.png'), fullPage: true });
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
|
||||
await page.locator('input[type="datetime-local"]').nth(0).fill('2026-08-20T00:00');
|
||||
await page.getByLabel('특수 접근 만료 시각').fill('2026-08-20T00:00');
|
||||
await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che');
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구');
|
||||
await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
|
||||
await expect(page.getByText('특수 접근 자격을 부여했습니다.')).toBeVisible();
|
||||
await expect(page.getByText(/RECOVERY · che/)).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true });
|
||||
|
||||
const gracePanel = page.getByRole('heading', { name: 'Kakao 인증 유예' }).locator('..');
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
|
||||
await gracePanel.locator('input[type="datetime-local"]').fill('2026-08-20T00:00');
|
||||
await page.getByRole('button', { name: '유예 연장', exact: true }).click();
|
||||
await expect(page.getByText('OAuth 유예 연장 완료')).toBeVisible();
|
||||
await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible();
|
||||
@@ -160,6 +202,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
await deletionButton.click();
|
||||
await expect(page.getByText(/탈퇴 예약 완료/)).toBeVisible();
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
|
||||
|
||||
await page.getByRole('link', { name: '감사 로그' }).click();
|
||||
|
||||
@@ -119,6 +119,26 @@ type KakaoGracePolicy = {
|
||||
graceEndsAt: string | null;
|
||||
generalCreationGraceDays: number;
|
||||
accessGraceDays: number;
|
||||
specialAccess: {
|
||||
kind: 'OPERATOR' | SpecialAccountAccessGrant['kind'];
|
||||
grantId: string | null;
|
||||
expiresAt: string | null;
|
||||
allowsGeneralCreation: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type SpecialAccountAccessGrant = {
|
||||
id: string;
|
||||
userId: string;
|
||||
kind: 'TESTER' | 'RECOVERY' | 'OTHER';
|
||||
profiles: string[];
|
||||
allowsGeneralCreation: boolean;
|
||||
expiresAt?: string;
|
||||
reason: string;
|
||||
grantedByUserId: string;
|
||||
revokedAt?: string;
|
||||
revokedReason?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AdminPublicUser = {
|
||||
@@ -197,6 +217,7 @@ type AdminClient = {
|
||||
kakaoVerified: boolean;
|
||||
kakaoGraceStartedAt: string;
|
||||
kakaoGraceUntil: string | null;
|
||||
specialAccessGrants: SpecialAccountAccessGrant[];
|
||||
profiles: KakaoGracePolicy[];
|
||||
}>;
|
||||
};
|
||||
@@ -205,6 +226,19 @@ type AdminClient = {
|
||||
kakaoGraceUntil: string | null;
|
||||
}>;
|
||||
};
|
||||
grantSpecialAccess: {
|
||||
mutate: (input: {
|
||||
userId: string;
|
||||
kind: SpecialAccountAccessGrant['kind'];
|
||||
profiles: string[];
|
||||
allowsGeneralCreation: boolean;
|
||||
expiresAt: string | null;
|
||||
reason: string;
|
||||
}) => Promise<SpecialAccountAccessGrant>;
|
||||
};
|
||||
revokeSpecialAccess: {
|
||||
mutate: (input: { userId: string; grantId: string; reason: string }) => Promise<SpecialAccountAccessGrant>;
|
||||
};
|
||||
listHistory: {
|
||||
query: (input: { userId: string; limit?: number }) => Promise<AdminAuditEvent[]>;
|
||||
};
|
||||
@@ -407,6 +441,12 @@ const deletionRetentionDays = ref(30);
|
||||
const kakaoGraceUntil = ref('');
|
||||
const kakaoGraceStatus = ref('');
|
||||
const kakaoPolicies = ref<KakaoGracePolicy[]>([]);
|
||||
const specialAccessGrants = ref<SpecialAccountAccessGrant[]>([]);
|
||||
const specialAccessKind = ref<SpecialAccountAccessGrant['kind']>('RECOVERY');
|
||||
const specialAccessProfiles = ref('');
|
||||
const specialAccessAllowsGeneralCreation = ref(true);
|
||||
const specialAccessExpiresAt = ref('');
|
||||
const specialAccessStatus = ref('');
|
||||
const userHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditStatus = ref('');
|
||||
@@ -690,6 +730,7 @@ const lookupUser = async () => {
|
||||
adminClient.users.listHistory.query({ userId: result.id, limit: 50 }),
|
||||
]);
|
||||
kakaoPolicies.value = grace.profiles;
|
||||
specialAccessGrants.value = grace.specialAccessGrants;
|
||||
kakaoGraceUntil.value = grace.kakaoGraceUntil ? toLocalInputValue(grace.kakaoGraceUntil) : '';
|
||||
userHistory.value = history;
|
||||
} catch (error) {
|
||||
@@ -764,12 +805,56 @@ const updateKakaoGrace = async (clear = false) => {
|
||||
kakaoGraceStatus.value = result.kakaoGraceUntil ? 'OAuth 유예 연장 완료' : '개별 유예 해제 완료';
|
||||
const grace = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||
kakaoPolicies.value = grace.profiles;
|
||||
specialAccessGrants.value = grace.specialAccessGrants;
|
||||
await refreshUserHistory();
|
||||
} catch {
|
||||
kakaoGraceStatus.value = 'OAuth 유예 변경 실패';
|
||||
}
|
||||
};
|
||||
|
||||
const grantSpecialAccess = async () => {
|
||||
if (!userResult.value) return;
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
specialAccessStatus.value = '';
|
||||
try {
|
||||
await adminClient.users.grantSpecialAccess.mutate({
|
||||
userId: userResult.value.id,
|
||||
kind: specialAccessKind.value,
|
||||
profiles: specialAccessProfiles.value
|
||||
.split(',')
|
||||
.map((profile) => profile.trim())
|
||||
.filter(Boolean),
|
||||
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
|
||||
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null,
|
||||
reason,
|
||||
});
|
||||
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||
kakaoPolicies.value = policy.profiles;
|
||||
specialAccessGrants.value = policy.specialAccessGrants;
|
||||
specialAccessStatus.value = '특수 접근 자격을 부여했습니다.';
|
||||
await refreshUserHistory();
|
||||
} catch {
|
||||
specialAccessStatus.value = '특수 접근 자격 부여에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSpecialAccess = async (grantId: string) => {
|
||||
if (!userResult.value) return;
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
try {
|
||||
await adminClient.users.revokeSpecialAccess.mutate({ userId: userResult.value.id, grantId, reason });
|
||||
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||
kakaoPolicies.value = policy.profiles;
|
||||
specialAccessGrants.value = policy.specialAccessGrants;
|
||||
specialAccessStatus.value = '특수 접근 자격을 해제했습니다.';
|
||||
await refreshUserHistory();
|
||||
} catch {
|
||||
specialAccessStatus.value = '특수 접근 자격 해제에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserPassword = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
@@ -1256,6 +1341,84 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-amber-800/60 rounded-lg p-5 space-y-4">
|
||||
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
|
||||
<div class="text-xs text-zinc-400">
|
||||
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서
|
||||
서버 범위와 만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<select
|
||||
v-model="specialAccessKind"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
:disabled="!hasUser"
|
||||
>
|
||||
<option value="RECOVERY">휴대폰 분실·계정 복구</option>
|
||||
<option value="TESTER">특수 테스트</option>
|
||||
<option value="OTHER">기타 예외</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="specialAccessExpiresAt"
|
||||
type="datetime-local"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
:disabled="!hasUser"
|
||||
aria-label="특수 접근 만료 시각"
|
||||
/>
|
||||
<input
|
||||
v-model="specialAccessProfiles"
|
||||
type="text"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="che 또는 che:2 (쉼표 구분, 비우면 전체)"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-300 px-2">
|
||||
<input
|
||||
v-model="specialAccessAllowsGeneralCreation"
|
||||
type="checkbox"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
장수 생성 허용
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
|
||||
:disabled="!hasUser"
|
||||
@click="grantSpecialAccess"
|
||||
>
|
||||
특수 접근 부여
|
||||
</button>
|
||||
<div class="text-xs text-zinc-500">{{ specialAccessStatus }}</div>
|
||||
<div v-if="specialAccessGrants.length" class="space-y-2">
|
||||
<div
|
||||
v-for="grant in specialAccessGrants"
|
||||
:key="grant.id"
|
||||
class="bg-black/30 border border-zinc-800 rounded p-3 text-xs space-y-1"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-semibold text-amber-200">
|
||||
{{ grant.kind }} · {{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!grant.revokedAt"
|
||||
class="bg-red-900 hover:bg-red-800 text-red-100 px-3 py-1 rounded"
|
||||
@click="revokeSpecialAccess(grant.id)"
|
||||
>
|
||||
해제
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
|
||||
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }}
|
||||
</div>
|
||||
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
|
||||
<div v-if="grant.revokedAt" class="text-red-300">
|
||||
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} ·
|
||||
{{ grant.revokedReason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
|
||||
<div class="text-xs text-zinc-500">
|
||||
@@ -1292,6 +1455,7 @@ onMounted(() => {
|
||||
<th>접근</th>
|
||||
<th>장수 생성</th>
|
||||
<th>기본 접근 유예</th>
|
||||
<th>특수 자격</th>
|
||||
<th>종료</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1305,6 +1469,7 @@ onMounted(() => {
|
||||
<td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td>
|
||||
<td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td>
|
||||
<td class="text-center">{{ policy.accessGraceDays }}일</td>
|
||||
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
|
||||
<td class="text-center">
|
||||
{{
|
||||
policy.graceEndsAt
|
||||
|
||||
@@ -291,7 +291,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
v-if="profile.localAccountPolicy?.specialAccess"
|
||||
class="mt-2 text-xs text-emerald-300"
|
||||
>
|
||||
특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
profile.localAccountPolicy?.requiresKakaoVerification &&
|
||||
!profile.localAccountPolicy.canCreateGeneral
|
||||
"
|
||||
|
||||
Reference in New Issue
Block a user