feat(gateway): add special account access

This commit is contained in:
2026-08-08 16:33:54 +00:00
parent 6a333cdf05
commit 77b1051ac2
22 changed files with 1060 additions and 15 deletions
+90
View File
@@ -25,6 +25,13 @@ import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
const zUserRoleMode = z.enum(['set', 'grant', 'revoke']);
const zSpecialAccountAccessKind = z.enum(['TESTER', 'RECOVERY', 'OTHER']);
const zSpecialAccessProfile = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/);
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zServerAction = z.enum([
'RESUME',
@@ -637,21 +644,104 @@ export const adminRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
const profiles = await ctx.profiles.listProfiles();
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
return {
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
kakaoGraceUntil: user.kakaoGraceUntil ?? null,
specialAccessGrants,
profiles: profiles.map((profile) => ({
profileName: profile.profileName,
...resolveLocalAccountProfilePolicy({
profile: profile.profile,
profileName: profile.profileName,
profileMeta: readMetaObject(profile.meta),
defaultGraceDays: (ctx as GatewayApiContext).localAccountGraceDays,
user,
specialAccessGrants,
}),
})),
};
}),
grantSpecialAccess: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
kind: zSpecialAccountAccessKind,
profiles: z.array(zSpecialAccessProfile).max(20).default([]),
allowsGeneralCreation: z.boolean().default(true),
expiresAt: z.string().datetime().nullable(),
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.' });
}
const adminAuth = requireAdminAuth(ctx);
assertTargetUserManageable(adminAuth, user);
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
const now = new Date();
if (expiresAt && expiresAt.getTime() <= now.getTime()) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Special access must end in the future.' });
}
if (input.kind === 'RECOVERY') {
if (!expiresAt) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access must expire.' });
}
if (expiresAt.getTime() > now.getTime() + 90 * 24 * 60 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access may last at most 90 days.' });
}
}
const profiles = [...new Set(input.profiles.map((profile) => profile.toLowerCase()))];
if (profiles.length > 0) {
const knownProfiles = await ctx.profiles.listProfiles();
const knownNames = new Set(
knownProfiles.flatMap((profile) => [profile.profile.toLowerCase(), profile.profileName.toLowerCase()])
);
const unknown = profiles.find((profile) => !knownNames.has(profile));
if (unknown) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown profile scope: ${unknown}` });
}
}
const grant = await ctx.users.createSpecialAccessGrant(input.userId, {
kind: input.kind,
profiles,
allowsGeneralCreation: input.allowsGeneralCreation,
expiresAt,
reason: input.reason,
grantedByUserId: adminAuth.user.id,
});
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-special-access-granted');
return grant;
}),
revokeSpecialAccess: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
grantId: z.string().uuid(),
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.' });
}
const adminAuth = requireAdminAuth(ctx);
assertTargetUserManageable(adminAuth, user);
const grant = await ctx.users.revokeSpecialAccessGrant(input.userId, input.grantId, {
revokedAt: new Date(),
revokedByUserId: adminAuth.user.id,
reason: input.reason,
});
if (!grant) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Active special access grant not found.' });
}
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-special-access-revoked');
return grant;
}),
updateKakaoGrace: userAdminProcedure
.input(
z.object({
@@ -1,7 +1,13 @@
import { randomUUID } from 'node:crypto';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type { CreateUserInput, UserIconRecord, UserRecord, UserRepository } from './userRepository.js';
import type {
CreateUserInput,
SpecialAccountAccessGrantRecord,
UserIconRecord,
UserRecord,
UserRepository,
} from './userRepository.js';
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
@@ -9,6 +15,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>();
const iconsById = new Map<string, UserIconRecord>();
const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>();
const nextRevision = (user: UserRecord, now: Date): string =>
new Date(
@@ -240,6 +247,40 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]> {
return [...specialAccessGrantsById.values()]
.filter((grant) => grant.userId === userId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
},
async createSpecialAccessGrant(userId, input): Promise<SpecialAccountAccessGrantRecord> {
if (![...usersByName.values()].some((user) => user.id === userId)) {
throw new Error('User not found.');
}
const now = new Date().toISOString();
const grant: SpecialAccountAccessGrantRecord = {
id: randomUUID(),
userId,
kind: input.kind,
profiles: [...input.profiles],
allowsGeneralCreation: input.allowsGeneralCreation,
expiresAt: input.expiresAt?.toISOString(),
reason: input.reason,
grantedByUserId: input.grantedByUserId,
createdAt: now,
};
specialAccessGrantsById.set(grant.id, grant);
return grant;
},
async revokeSpecialAccessGrant(userId, grantId, input): Promise<SpecialAccountAccessGrantRecord | null> {
const grant = specialAccessGrantsById.get(grantId);
if (!grant || grant.userId !== userId || grant.revokedAt) {
return null;
}
grant.revokedAt = input.revokedAt.toISOString();
grant.revokedByUserId = input.revokedByUserId;
grant.revokedReason = input.reason;
return grant;
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -389,6 +430,11 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
for (const [username, user] of usersByName.entries()) {
if (user.id === userId) {
usersByName.delete(username);
for (const [grantId, grant] of specialAccessGrantsById) {
if (grant.userId === userId) {
specialAccessGrantsById.delete(grantId);
}
}
if (user.oauthType === 'KAKAO' && user.oauthId) {
usersByOauthId.delete(`${user.oauthType}:${user.oauthId}`);
}
+71 -7
View File
@@ -1,4 +1,4 @@
import type { UserRecord } from './userRepository.js';
import type { SpecialAccountAccessGrantRecord, UserRecord } from './userRepository.js';
const GENERAL_CREATION_GRACE_PROFILES = new Set(['nya', 'pya', 'hwe']);
const ADMIN_ROLES = new Set(['superuser', 'admin', 'admin.superuser']);
@@ -12,6 +12,12 @@ export interface LocalAccountProfilePolicy {
graceEndsAt: string | null;
generalCreationGraceDays: number;
accessGraceDays: number;
specialAccess: {
kind: 'OPERATOR' | SpecialAccountAccessGrantRecord['kind'];
grantId: string | null;
expiresAt: string | null;
allowsGeneralCreation: boolean;
} | null;
}
const readGraceDays = (meta: Record<string, unknown>, key: string, fallback: number): number => {
@@ -22,17 +28,67 @@ const readGraceDays = (meta: Record<string, unknown>, key: string, fallback: num
return Math.min(Math.max(Math.floor(value), 0), 365);
};
const hasAdminBypass = (user: UserRecord): boolean =>
export const hasOperatorSpecialAccess = (user: UserRecord): boolean =>
user.roles.some((role) => ADMIN_ROLES.has(role) || role.startsWith('admin.'));
export const hasActiveSpecialAccountGrant = (
grants: readonly SpecialAccountAccessGrantRecord[],
now: Date = new Date()
): boolean =>
grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()));
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
const resolveSpecialAccess = (options: {
user: UserRecord;
grants: readonly SpecialAccountAccessGrantRecord[];
profile: string;
profileName: string;
now: Date;
}): LocalAccountProfilePolicy['specialAccess'] => {
if (hasOperatorSpecialAccess(options.user)) {
return {
kind: 'OPERATOR',
grantId: null,
expiresAt: null,
allowsGeneralCreation: true,
};
}
const active = options.grants.filter((grant) => {
if (grant.revokedAt || !appliesToProfile(grant, options.profile, options.profileName)) {
return false;
}
return !grant.expiresAt || new Date(grant.expiresAt).getTime() > options.now.getTime();
});
if (active.length === 0) {
return null;
}
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
const expiresAt = active.some((grant) => !grant.expiresAt)
? null
: active
.map((grant) => grant.expiresAt!)
.sort((left, right) => right.localeCompare(left))[0] ?? null;
return {
kind: selected.kind,
grantId: selected.id,
expiresAt,
allowsGeneralCreation: active.some((grant) => grant.allowsGeneralCreation),
};
};
export const resolveLocalAccountProfilePolicy = (options: {
profile: string;
profileName?: string;
profileMeta?: Record<string, unknown>;
defaultGraceDays: number;
user: UserRecord;
specialAccessGrants?: readonly SpecialAccountAccessGrantRecord[];
now?: Date;
}): LocalAccountProfilePolicy => {
const profile = options.profile.toLowerCase();
const profileName = (options.profileName ?? options.profile).toLowerCase();
const meta = options.profileMeta ?? {};
const defaultGraceDays = Math.min(Math.max(Math.floor(options.defaultGraceDays), 0), 365);
const accessGraceDays = readGraceDays(meta, 'localAccountAccessGraceDays', defaultGraceDays);
@@ -43,25 +99,33 @@ export const resolveLocalAccountProfilePolicy = (options: {
generalCreationDefault
);
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
const bypass = hasAdminBypass(options.user);
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
const now = options.now ?? new Date();
const specialAccess = resolveSpecialAccess({
user: options.user,
grants: options.specialAccessGrants ?? [],
profile,
profileName,
now,
});
const accessEndsAt = new Date(graceStartedAt.getTime() + accessGraceDays * DAY_MS);
const adminGraceUntil = options.user.kakaoGraceUntil ? new Date(options.user.kakaoGraceUntil) : null;
if (adminGraceUntil && Number.isFinite(adminGraceUntil.getTime()) && adminGraceUntil > accessEndsAt) {
accessEndsAt.setTime(adminGraceUntil.getTime());
}
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
const accessAllowed = kakaoVerified || bypass || now < accessEndsAt;
const canCreateGeneral = kakaoVerified || bypass || (accessAllowed && now < generalCreationEndsAt);
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
const canCreateGeneral =
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt);
return {
requiresKakaoVerification: !kakaoVerified && !bypass,
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
kakaoVerified,
accessAllowed,
canCreateGeneral,
graceEndsAt: kakaoVerified || bypass ? null : accessEndsAt.toISOString(),
graceEndsAt: kakaoVerified ? null : specialAccess ? specialAccess.expiresAt : accessEndsAt.toISOString(),
generalCreationGraceDays,
accessGraceDays,
specialAccess,
};
};
@@ -3,6 +3,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
CreateUserInput,
SpecialAccountAccessGrantRecord,
UserIconRecord,
UserOAuthInfo,
UserRecord,
@@ -111,6 +112,34 @@ const mapIcon = (row: {
retiredAt: row.retiredAt?.toISOString(),
});
const mapSpecialAccessGrant = (row: {
id: string;
userId: string;
kind: 'TESTER' | 'RECOVERY' | 'OTHER';
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt: Date | null;
reason: string;
grantedByUserId: string;
revokedAt: Date | null;
revokedByUserId: string | null;
revokedReason: string | null;
createdAt: Date;
}): SpecialAccountAccessGrantRecord => ({
id: row.id,
userId: row.userId,
kind: row.kind,
profiles: row.profiles,
allowsGeneralCreation: row.allowsGeneralCreation,
expiresAt: row.expiresAt?.toISOString(),
reason: row.reason,
grantedByUserId: row.grantedByUserId,
revokedAt: row.revokedAt?.toISOString(),
revokedByUserId: row.revokedByUserId ?? undefined,
revokedReason: row.revokedReason ?? undefined,
createdAt: row.createdAt.toISOString(),
});
export const createPostgresUserRepository = (
prisma: GatewayPrismaClient,
hasher: PasswordHasher = createSimplePasswordHasher()
@@ -299,6 +328,41 @@ export const createPostgresUserRepository = (
data: { kakaoGraceUntil: until },
});
},
async listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]> {
const rows = await prisma.specialAccountAccessGrant.findMany({
where: { userId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
return rows.map(mapSpecialAccessGrant);
},
async createSpecialAccessGrant(userId, input): Promise<SpecialAccountAccessGrantRecord> {
const row = await prisma.specialAccountAccessGrant.create({
data: {
userId,
kind: input.kind,
profiles: input.profiles,
allowsGeneralCreation: input.allowsGeneralCreation,
expiresAt: input.expiresAt,
reason: input.reason,
grantedByUserId: input.grantedByUserId,
},
});
return mapSpecialAccessGrant(row);
},
async revokeSpecialAccessGrant(userId, grantId, input): Promise<SpecialAccountAccessGrantRecord | null> {
const result = await prisma.specialAccountAccessGrant.updateMany({
where: { id: grantId, userId, revokedAt: null },
data: {
revokedAt: input.revokedAt,
revokedByUserId: input.revokedByUserId,
revokedReason: input.reason,
},
});
if (result.count !== 1) {
return null;
}
return mapSpecialAccessGrant(await prisma.specialAccountAccessGrant.findUniqueOrThrow({ where: { id: grantId } }));
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
@@ -38,6 +38,23 @@ export interface UserIconRecord {
retiredAt?: string;
}
export type SpecialAccountAccessKind = 'TESTER' | 'RECOVERY' | 'OTHER';
export interface SpecialAccountAccessGrantRecord {
id: string;
userId: string;
kind: SpecialAccountAccessKind;
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt?: string;
reason: string;
grantedByUserId: string;
revokedAt?: string;
revokedByUserId?: string;
revokedReason?: string;
createdAt: string;
}
export type AddUserIconResult =
{ ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'COOLDOWN' | 'LIMIT' | 'NOT_FOUND' };
@@ -134,6 +151,23 @@ export interface UserRepository {
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]>;
createSpecialAccessGrant(
userId: string,
input: {
kind: SpecialAccountAccessKind;
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt: Date | null;
reason: string;
grantedByUserId: string;
}
): Promise<SpecialAccountAccessGrantRecord>;
revokeSpecialAccessGrant(
userId: string,
grantId: string,
input: { revokedAt: Date; revokedByUserId: string; reason: string }
): Promise<SpecialAccountAccessGrantRecord | null>;
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
updateIconForDay(
userId: string,
+28 -1
View File
@@ -12,7 +12,11 @@ import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import {
hasActiveSpecialAccountGrant,
hasOperatorSpecialAccess,
resolveLocalAccountProfilePolicy,
} from './auth/localAccountPolicy.js';
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
@@ -131,14 +135,17 @@ export const appRouter = router({
localAccountPolicy: null,
}));
}
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
return Promise.all(
profileList.map(async (profile) => {
const record = await ctx.profiles.getProfile(profile.profileName);
const policy = resolveLocalAccountProfilePolicy({
profile: record?.profile ?? profile.profile,
profileName: profile.profileName,
profileMeta: record?.meta,
defaultGraceDays: ctx.localAccountGraceDays,
user,
specialAccessGrants,
});
return {
...profile,
@@ -765,6 +772,16 @@ export const appRouter = router({
});
}
if (user.oauthType === 'KAKAO') {
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
if (hasOperatorSpecialAccess(user) || hasActiveSpecialAccountGrant(specialAccessGrants)) {
const session = await ctx.sessions.createSession(user);
return {
status: 'login' as const,
user: toPublicUser(user),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
}
const ready = await verifyStoredKakaoIdentity({
user,
users: ctx.users,
@@ -881,9 +898,11 @@ export const appRouter = router({
}
const localAccountPolicy = resolveLocalAccountProfilePolicy({
profile,
profileName: input.profile,
profileMeta: profileRecord?.meta,
defaultGraceDays: ctx.localAccountGraceDays,
user,
specialAccessGrants: await ctx.users.listSpecialAccessGrants(user.id),
});
if (!localAccountPolicy.accessAllowed) {
throw new TRPCError({
@@ -932,6 +951,14 @@ export const appRouter = router({
canCreateGeneral: localAccountPolicy.canCreateGeneral,
requiresKakaoVerification: localAccountPolicy.requiresKakaoVerification,
graceEndsAt: localAccountPolicy.graceEndsAt,
...(localAccountPolicy.specialAccess
? {
specialAccess: {
kind: localAccountPolicy.specialAccess.kind,
expiresAt: localAccountPolicy.specialAccess.expiresAt,
},
}
: {}),
},
} as const;
const gameToken = encryptGameSessionToken(payload, ctx.gameTokenSecret);
@@ -916,6 +916,70 @@ describe('Gateway administrator account controls', () => {
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-kakao-grace-updated' });
});
it('grants and revokes profile-scoped recovery access with an audit trail', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'recovery-target',
password: 'secretpass',
displayName: 'Recovery Target',
});
target.oauthType = 'KAKAO';
target.oauthId = 'lost-phone-kakao-id';
target.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
const grant = await harness.caller.admin.users.grantSpecialAccess({
userId: target.id,
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt,
reason: '휴대폰 분실 본인 확인 완료',
});
expect(grant).toMatchObject({
userId: target.id,
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt,
grantedByUserId: harness.admin.id,
});
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-granted' });
await expect(
harness.caller.admin.users.revokeSpecialAccess({
userId: target.id,
grantId: grant.id,
reason: 'Kakao 인증 수단 복구 완료',
})
).resolves.toMatchObject({ id: grant.id, revokedReason: 'Kakao 인증 수단 복구 완료' });
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-revoked' });
expect(harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)).toEqual([
'admin.users.grantSpecialAccess',
'admin.users.revokeSpecialAccess',
]);
});
it('requires recovery access to expire within 90 days', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'unsafe-recovery-target',
password: 'secretpass',
displayName: 'Unsafe Recovery Target',
});
await expect(
harness.caller.admin.users.grantSpecialAccess({
userId: target.id,
kind: 'RECOVERY',
profiles: [],
allowsGeneralCreation: true,
expiresAt: null,
reason: '무기한 복구 예외 거부',
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('schedules deletion with retention and prevents administrator self-deletion', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
@@ -249,6 +249,29 @@ describe('admin security over HTTP transport', () => {
expect((await harness.users.findById(harness.target.id))?.roles).toEqual(['user']);
});
it('rejects an unauthenticated special-access grant at the HTTP header boundary', async () => {
const harness = await createHarness();
const rejected = await postTrpc(harness.baseUrl, 'admin.users.grantSpecialAccess', {
userId: harness.target.id,
kind: 'TESTER',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: null,
reason: '미인증 특수 접근 부여 거부 테스트',
});
expect(rejected.response.status).toBe(401);
expect(rejected.body).toMatchObject({
error: {
data: {
code: 'UNAUTHORIZED',
},
},
});
expect(await harness.users.listSpecialAccessGrants(harness.target.id)).toEqual([]);
});
it('rejects self-escalation and set-mode removal outside a scoped administrator role', async () => {
const harness = await createHarness();
+133
View File
@@ -434,6 +434,139 @@ describe('gateway auth flow', () => {
});
});
it('issues a CHE game token to an expired tester with an active special access grant', async () => {
const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 0 });
const register = await caller.auth.registerLocal({
username: 'special-tester',
credential: sealPassword('tester-password'),
displayName: '특수테스터',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
const user = await users.findByUsername('special-tester');
expect(user).not.toBeNull();
if (!user) throw new Error('Expected local tester.');
await users.createSpecialAccessGrant(user.id, {
kind: 'TESTER',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: null,
reason: 'CHE 회귀 검증',
grantedByUserId: 'admin-id',
});
const issued = await caller.auth.issueGameSession({
sessionToken: register.sessionToken,
profile: 'che:default',
});
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
expect(payload?.identity).toMatchObject({
kakaoVerified: false,
canCreateGeneral: true,
requiresKakaoVerification: false,
specialAccess: { kind: 'TESTER', expiresAt: null },
});
});
it('lets a Kakao-linked recovery account log in with its password while the grant is active', async () => {
const { caller, users, sealPassword } = buildCaller();
await caller.auth.registerLocal({
username: 'lost-phone-user',
credential: sealPassword('recovery-password'),
displayName: '분실복구유저',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
const user = await users.findByUsername('lost-phone-user');
expect(user).not.toBeNull();
if (!user) throw new Error('Expected recovery user.');
user.oauthType = 'KAKAO';
user.oauthId = 'lost-phone-kakao-id';
user.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
await users.createSpecialAccessGrant(user.id, {
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
reason: '휴대폰 분실 본인 확인 완료',
grantedByUserId: 'admin-id',
});
await expect(
caller.auth.login({
username: 'lost-phone-user',
credential: sealPassword('recovery-password'),
})
).resolves.toMatchObject({ status: 'login', user: { username: 'lost-phone-user' } });
});
it('lets a Kakao-linked operator log in with its password without a grant', async () => {
const { caller, users, sealPassword } = buildCaller();
await caller.auth.registerLocal({
username: 'oauth-free-operator',
credential: sealPassword('operator-password'),
displayName: '복구운영자',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
const user = await users.findByUsername('oauth-free-operator');
expect(user).not.toBeNull();
if (!user) throw new Error('Expected operator user.');
user.oauthType = 'KAKAO';
user.oauthId = 'operator-kakao-id';
user.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
await users.updateRoles(user.id, ['user', 'admin.users.manage']);
await expect(
caller.auth.login({
username: 'oauth-free-operator',
credential: sealPassword('operator-password'),
})
).resolves.toMatchObject({ status: 'login', user: { username: 'oauth-free-operator' } });
});
it('keeps an active server sanction authoritative over special access', async () => {
const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 0 });
const register = await caller.auth.registerLocal({
username: 'sanctioned-special-tester',
credential: sealPassword('tester-password'),
displayName: '제재특수테스터',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
const user = await users.findByUsername('sanctioned-special-tester');
expect(user).not.toBeNull();
if (!user) throw new Error('Expected sanctioned local tester.');
await users.createSpecialAccessGrant(user.id, {
kind: 'TESTER',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: null,
reason: 'CHE 회귀 검증',
grantedByUserId: 'admin-id',
});
await users.updateSanctions(user.id, {
serverRestrictions: {
che: {
blockedFeatures: ['login'],
until: '2099-01-01T00:00:00.000Z',
},
},
});
await expect(
caller.auth.issueGameSession({
sessionToken: register.sessionToken,
profile: 'che:default',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('links Kakao to the logged-in local account instead of creating a second user', async () => {
const { caller, users, sealPassword, setSessionHeader, sentTalkMessages } = buildCaller();
const register = await caller.auth.registerLocal({
@@ -107,4 +107,90 @@ describe('local account profile policy', () => {
generalCreationGraceDays: 0,
});
});
it('treats every administrator role as permanent operator access', () => {
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
user.roles = ['user', 'admin.users.manage'];
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
profileName: 'che:2',
defaultGraceDays: 0,
user,
now: new Date('2026-08-08T00:00:00.000Z'),
});
expect(policy).toMatchObject({
accessAllowed: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
specialAccess: {
kind: 'OPERATOR',
grantId: null,
expiresAt: null,
allowsGeneralCreation: true,
},
});
});
it('applies a profile-scoped tester grant to CHE including general creation', () => {
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
profileName: 'che:2',
defaultGraceDays: 0,
user,
specialAccessGrants: [
{
id: 'grant-1',
userId: user.id,
kind: 'TESTER',
profiles: ['che'],
allowsGeneralCreation: true,
reason: '고정 시나리오 검증',
grantedByUserId: 'admin-id',
createdAt: '2026-08-01T00:00:00.000Z',
},
],
now: new Date('2026-08-08T00:00:00.000Z'),
});
expect(policy).toMatchObject({
accessAllowed: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
specialAccess: { kind: 'TESTER', grantId: 'grant-1', allowsGeneralCreation: true },
});
});
it('ignores expired, revoked, and different-profile grants', () => {
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
const baseGrant = {
userId: user.id,
kind: 'RECOVERY' as const,
profiles: ['che'],
allowsGeneralCreation: true,
reason: '단말 분실 복구',
grantedByUserId: 'admin-id',
createdAt: '2026-08-01T00:00:00.000Z',
};
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
profileName: 'che:2',
defaultGraceDays: 0,
user,
specialAccessGrants: [
{ ...baseGrant, id: 'expired', expiresAt: '2026-08-07T00:00:00.000Z' },
{ ...baseGrant, id: 'revoked', revokedAt: '2026-08-07T00:00:00.000Z' },
{ ...baseGrant, id: 'other-profile', profiles: ['hwe'], expiresAt: '2026-09-01T00:00:00.000Z' },
],
now: new Date('2026-08-08T00:00:00.000Z'),
});
expect(policy).toMatchObject({
accessAllowed: false,
canCreateGeneral: false,
requiresKakaoVerification: true,
specialAccess: null,
});
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ describe('readReleaseManifest', () => {
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
gatewaySchemaHead: '20260808000000_add_kakao_talk_verification',
gatewaySchemaHead: '20260808001000_add_special_account_access_grants',
gameSchemaHead: '20260803000000_add_logical_game_clock',
});
});
@@ -0,0 +1,90 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const userId = '14a4d550-e92c-4aec-81e1-e6235dc17ded';
const adminId = 'b6b327d8-e95e-4858-9b66-4fd22a286145';
const assertDedicatedSchema = (): void => {
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
}
};
integration('special account access PostgreSQL boundary', () => {
let db: GatewayPrismaClient;
let closeDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
assertDedicatedSchema();
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.appUser.deleteMany({ where: { id: userId } });
await db.appUser.create({
data: {
id: userId,
loginId: 'special-access-integration',
displayName: '특수 접근 통합',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
},
});
});
afterAll(async () => {
await db?.appUser.deleteMany({ where: { id: userId } });
await closeDb?.();
});
it('persists profile scope and preserves revocation provenance', async () => {
const users = createPostgresUserRepository(db);
const grant = await users.createSpecialAccessGrant(userId, {
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: new Date('2026-09-01T00:00:00.000Z'),
reason: '분실 단말 복구 기간',
grantedByUserId: adminId,
});
await expect(users.listSpecialAccessGrants(userId)).resolves.toEqual([
expect.objectContaining({
id: grant.id,
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: '2026-09-01T00:00:00.000Z',
grantedByUserId: adminId,
}),
]);
const revoked = await users.revokeSpecialAccessGrant(userId, grant.id, {
revokedAt: new Date('2026-08-20T00:00:00.000Z'),
revokedByUserId: adminId,
reason: 'Kakao 인증 복구 완료',
});
expect(revoked).toMatchObject({
id: grant.id,
revokedAt: '2026-08-20T00:00:00.000Z',
revokedByUserId: adminId,
revokedReason: 'Kakao 인증 복구 완료',
});
await expect(
users.revokeSpecialAccessGrant(userId, grant.id, {
revokedAt: new Date('2026-08-21T00:00:00.000Z'),
revokedByUserId: adminId,
reason: '중복 해제',
})
).resolves.toBeNull();
});
});
@@ -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
+7 -1
View File
@@ -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
"