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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user