feat: add admin user management features and update user repository methods

- Implemented updateRoles, updateSanctions, and deleteUser methods in both in-memory and PostgreSQL user repositories.
- Enhanced UserSanctions and UserServerRestriction interfaces to include additional properties.
- Added AdminView component for admin functionalities including user lookup, role management, sanctions application, and profile management.
- Integrated admin token handling in TRPC client for secure API requests.
- Updated routing to include admin dashboard and linked it in the default layout.
This commit is contained in:
2026-01-03 15:28:51 +00:00
parent a9609dcc19
commit e5bd6d89ab
10 changed files with 1512 additions and 0 deletions
+424
View File
@@ -1,7 +1,10 @@
import { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { procedure, router } from './trpc.js';
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
import {
GATEWAY_BUILD_STATUSES,
GATEWAY_PROFILE_STATUSES,
@@ -9,6 +12,17 @@ import {
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
const zUserRoleMode = z.enum(['set', 'grant', 'revoke']);
const zServerAction = z.enum([
'RESUME',
'PAUSE',
'STOP',
'ACCELERATE',
'DELAY',
'RESET_NOW',
'RESET_SCHEDULED',
'SHUTDOWN',
]);
const adminProcedure = procedure.use(({ ctx, next }) => {
if (!ctx.adminToken) {
@@ -32,7 +46,328 @@ const adminProcedure = procedure.use(({ ctx, next }) => {
return next();
});
const zUserLookupInput = z
.object({
id: z.string().min(1).optional(),
username: z.string().min(1).optional(),
email: z.string().min(3).optional(),
})
.refine((value) => Boolean(value.id || value.username || value.email), {
message: 'id, username, or email must be provided.',
});
const zServerRestriction = z.object({
blockedFeatures: z.array(z.string().min(1)).optional(),
until: z.string().datetime().nullable().optional(),
reason: z.string().max(200).nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
});
const zSanctionsPatch = z.object({
bannedUntil: z.string().datetime().nullable().optional(),
mutedUntil: z.string().datetime().nullable().optional(),
suspendedUntil: z.string().datetime().nullable().optional(),
warningCount: z.number().int().min(0).nullable().optional(),
flags: z.array(z.string().min(1)).nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
profileIconResetAt: z.string().datetime().nullable().optional(),
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
});
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
// 제재 패치 입력을 현재 제재 상태에 병합한다.
const applySanctionsPatch = (
current: UserSanctions,
patch: SanctionsPatch
): UserSanctions => {
const next: UserSanctions = { ...current };
const applyField = <K extends keyof UserSanctions>(
key: K,
value: UserSanctions[K] | null | undefined
): void => {
if (value === undefined) {
return;
}
if (value === null) {
delete next[key];
return;
}
next[key] = value;
};
applyField('bannedUntil', patch.bannedUntil);
applyField('mutedUntil', patch.mutedUntil);
applyField('suspendedUntil', patch.suspendedUntil);
applyField('warningCount', patch.warningCount);
applyField('flags', patch.flags);
applyField('notes', patch.notes);
applyField('profileIconResetAt', patch.profileIconResetAt);
if (patch.serverRestrictions !== undefined) {
if (patch.serverRestrictions === null) {
delete next.serverRestrictions;
} else {
const existing = { ...(next.serverRestrictions ?? {}) };
for (const [profile, restriction] of Object.entries(
patch.serverRestrictions
)) {
if (!restriction) {
delete existing[profile];
} else {
const merged: UserServerRestriction = {
...(existing[profile] ?? {}),
};
if (restriction.blockedFeatures !== undefined) {
merged.blockedFeatures = restriction.blockedFeatures ?? undefined;
}
if (restriction.until !== undefined) {
merged.until = restriction.until ?? undefined;
}
if (restriction.reason !== undefined) {
merged.reason = restriction.reason ?? undefined;
}
if (restriction.notes !== undefined) {
merged.notes = restriction.notes ?? undefined;
}
existing[profile] = merged;
}
}
next.serverRestrictions = existing;
}
}
return next;
};
const buildAdminPassword = (): string => randomBytes(6).toString('hex');
// 프로필 메타를 안전하게 읽고, 패치를 병합한다.
const readMetaObject = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== 'object') {
return {};
}
return value as Record<string, unknown>;
};
const applyMetaPatch = (
meta: Record<string, unknown>,
patch: Record<string, unknown | null | undefined>
): Record<string, unknown> => {
const next = { ...meta };
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
continue;
}
if (value === null) {
delete next[key];
continue;
}
next[key] = value;
}
return next;
};
export const adminRouter = router({
system: router({
getNotice: adminProcedure.query(async ({ ctx }) => {
const setting = await ctx.prisma.systemSetting.findUnique({
where: { id: 1 },
});
return { notice: setting?.notice ?? '' };
}),
setNotice: adminProcedure
.input(
z.object({
notice: z.string().max(4000),
})
)
.mutation(async ({ ctx, input }) => {
const setting = await ctx.prisma.systemSetting.upsert({
where: { id: 1 },
create: {
id: 1,
notice: input.notice,
},
update: {
notice: input.notice,
},
});
return { notice: setting.notice };
}),
}),
users: router({
lookup: adminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
const user =
input.id
? await ctx.users.findById(input.id)
: input.username
? await ctx.users.findByUsername(input.username)
: input.email
? await ctx.users.findByEmail(input.email)
: null;
if (!user) {
return null;
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
roles: user.roles,
sanctions: user.sanctions,
oauthType: user.oauthType,
oauthId: user.oauthId,
email: user.email,
createdAt: user.createdAt,
};
}),
resetPassword: adminProcedure
.input(
z.object({
userId: z.string().min(1),
newPassword: z.string().min(6).max(128).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const password = input.newPassword ?? buildAdminPassword();
await ctx.users.updatePassword(input.userId, password);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-password-reset'
);
return { password };
}),
updateRoles: adminProcedure
.input(
z.object({
userId: z.string().min(1),
roles: z.array(z.string().min(1)).min(1),
mode: zUserRoleMode.optional(),
})
)
.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 mode = input.mode ?? 'set';
const roles = new Set(user.roles);
if (mode === 'set') {
roles.clear();
for (const role of input.roles) {
roles.add(role);
}
} else if (mode === 'grant') {
for (const role of input.roles) {
roles.add(role);
}
} else {
for (const role of input.roles) {
roles.delete(role);
}
}
const nextRoles = Array.from(roles);
await ctx.users.updateRoles(input.userId, nextRoles);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-roles-updated'
);
return { roles: nextRoles };
}),
updateSanctions: adminProcedure
.input(
z.object({
userId: z.string().min(1),
patch: zSanctionsPatch,
})
)
.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 next = applySanctionsPatch(user.sanctions, input.patch);
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-sanctions-updated'
);
return { sanctions: next };
}),
setServerRestriction: adminProcedure
.input(
z.object({
userId: z.string().min(1),
profile: z.string().min(1).max(64),
restriction: zServerRestriction.nullable(),
})
)
.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 patch: SanctionsPatch = {
serverRestrictions: {
[input.profile]: input.restriction ?? null,
},
};
const next = applySanctionsPatch(user.sanctions, patch);
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-server-restriction'
);
return { sanctions: next };
}),
resetProfileIcon: adminProcedure
.input(
z.object({
userId: z.string().min(1),
})
)
.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 next = applySanctionsPatch(user.sanctions, {
profileIconResetAt: new Date().toISOString(),
});
await ctx.users.updateSanctions(input.userId, next);
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-profile-icon-reset'
);
return { profileIconResetAt: next.profileIconResetAt };
}),
forceDelete: adminProcedure
.input(
z.object({
userId: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.flushPublisher.publishUserFlush(
input.userId,
'admin-force-withdraw'
);
await ctx.users.deleteUser(input.userId);
return { ok: true };
}),
}),
profiles: router({
list: adminProcedure.query(async ({ ctx }) => {
const profiles = await ctx.profiles.listProfiles();
@@ -113,6 +448,95 @@ export const adminRouter = router({
await ctx.orchestrator.reconcileNow();
return result;
}),
updateMeta: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
patch: z.object({
korName: z.string().min(1).max(64).nullable().optional(),
color: z.string().min(1).max(32).nullable().optional(),
inGameNotice: z.string().max(4000).nullable().optional(),
profileImageUrl: z.string().max(2048).nullable().optional(),
}),
})
)
.mutation(async ({ ctx, input }) => {
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Profile not found.',
});
}
const meta = readMetaObject(profile.meta);
const nextMeta = applyMetaPatch(meta, input.patch);
return ctx.profiles.updateMeta(input.profileName, nextMeta);
}),
requestAction: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
action: zServerAction,
durationMinutes: z.number().int().min(1).max(1440).optional(),
scheduledAt: z.string().datetime().optional(),
reason: z.string().max(200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
if (
(input.action === 'ACCELERATE' || input.action === 'DELAY') &&
!input.durationMinutes
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'durationMinutes is required for acceleration or delay.',
});
}
if (input.action === 'RESET_SCHEDULED' && !input.scheduledAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledAt is required for scheduled reset.',
});
}
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Profile not found.',
});
}
const statusMap = {
RESUME: 'RUNNING',
PAUSE: 'PAUSED',
STOP: 'STOPPED',
SHUTDOWN: 'DISABLED',
} as const;
const mappedStatus = statusMap[input.action as keyof typeof statusMap];
if (mappedStatus) {
await ctx.profiles.updateStatus(input.profileName, mappedStatus);
await ctx.orchestrator.reconcileNow();
}
const meta = readMetaObject(profile.meta);
const actionLog = Array.isArray(meta.adminActions)
? meta.adminActions.filter((entry) => entry && typeof entry === 'object')
: [];
const actionRecord = {
action: input.action,
requestedAt: new Date().toISOString(),
durationMinutes: input.durationMinutes ?? null,
scheduledAt: input.scheduledAt ?? null,
reason: input.reason ?? null,
status: 'REQUESTED',
};
const nextMeta = {
...meta,
adminActions: [...actionLog, actionRecord],
};
await ctx.profiles.updateMeta(input.profileName, nextMeta);
return { ok: true, action: actionRecord };
}),
requestBuild: adminProcedure
.input(
z.object({
@@ -81,5 +81,38 @@ export const createInMemoryUserRepository = (
}
throw new Error('User not found.');
},
async updateRoles(userId: string, roles: string[]): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.roles = [...roles];
return;
}
}
throw new Error('User not found.');
},
async updateSanctions(userId: string, sanctions: UserRecord['sanctions']): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.sanctions = { ...sanctions };
return;
}
}
throw new Error('User not found.');
},
async deleteUser(userId: string): Promise<void> {
for (const [username, user] of usersByName.entries()) {
if (user.id === userId) {
usersByName.delete(username);
if (user.oauthType === 'KAKAO' && user.oauthId) {
usersByOauthId.delete(`${user.oauthType}:${user.oauthId}`);
}
if (user.email) {
usersByEmail.delete(user.email.toLowerCase());
}
return;
}
}
throw new Error('User not found.');
},
};
};
@@ -129,5 +129,26 @@ export const createPostgresUserRepository = (
},
});
},
async updateRoles(userId: string, roles: string[]): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: {
roles: roles as GatewayPrisma.JsonArray,
},
});
},
async updateSanctions(userId: string, sanctions: UserSanctions): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
data: {
sanctions: sanctions as GatewayPrisma.JsonObject,
},
});
},
async deleteUser(userId: string): Promise<void> {
await prisma.appUser.delete({
where: { id: userId },
});
},
};
};
@@ -28,6 +28,15 @@ export interface UserSanctions {
warningCount?: number;
flags?: string[];
notes?: string;
profileIconResetAt?: string;
serverRestrictions?: Record<string, UserServerRestriction>;
}
export interface UserServerRestriction {
blockedFeatures?: string[];
until?: string;
reason?: string;
notes?: string;
}
export const toPublicUser = (user: UserRecord): PublicUser => ({
@@ -59,6 +68,9 @@ export interface UserRepository {
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
updatePassword(userId: string, password: string): Promise<void>;
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
deleteUser(userId: string): Promise<void>;
}
export interface UserOAuthInfo {
@@ -81,6 +81,10 @@ export interface GatewayProfileRepository {
lastUsedAt?: string | null;
}
): Promise<GatewayProfileRecord | null>;
updateMeta(
profileName: string,
meta: Record<string, unknown>
): Promise<GatewayProfileRecord | null>;
listReservedToStart(now: Date): Promise<GatewayProfileRecord[]>;
findQueuedBuild(): Promise<GatewayProfileRecord | null>;
updateLastError(profileName: string, lastError: string | null): Promise<void>;
@@ -303,6 +307,19 @@ export const createGatewayProfileRepository = (
});
return row ? mapProfile(row) : null;
},
async updateMeta(
profileName: string,
meta: Record<string, unknown>
): Promise<GatewayProfileRecord | null> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient;
const row = await gatewayProfile.update({
where: { profileName },
data: {
meta: meta as GatewayPrisma.JsonObject,
},
});
return row ? mapProfile(row) : null;
},
async listReservedToStart(now: Date): Promise<GatewayProfileRecord[]> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient;
const rows = await gatewayProfile.findMany({