From e5bd6d89ab32a02c18aee2112089a32dd7e4e07c Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 3 Jan 2026 15:28:51 +0000 Subject: [PATCH] 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. --- app/gateway-api/src/adminRouter.ts | 424 ++++++++ .../src/auth/inMemoryUserRepository.ts | 33 + .../src/auth/postgresUserRepository.ts | 21 + app/gateway-api/src/auth/userRepository.ts | 12 + .../src/orchestrator/profileRepository.ts | 17 + .../src/layouts/DefaultLayout.vue | 1 + app/gateway-frontend/src/router/index.ts | 6 + app/gateway-frontend/src/utils/trpc.ts | 11 + app/gateway-frontend/src/views/AdminView.vue | 978 ++++++++++++++++++ packages/common/src/auth/gameToken.ts | 9 + 10 files changed, 1512 insertions(+) create mode 100644 app/gateway-frontend/src/views/AdminView.vue diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 13e7d51..da6e009 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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; + +// 제재 패치 입력을 현재 제재 상태에 병합한다. +const applySanctionsPatch = ( + current: UserSanctions, + patch: SanctionsPatch +): UserSanctions => { + const next: UserSanctions = { ...current }; + const applyField = ( + 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 => { + if (!value || typeof value !== 'object') { + return {}; + } + return value as Record; +}; + +const applyMetaPatch = ( + meta: Record, + patch: Record +): Record => { + 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({ diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 5bea0a3..379bbec 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -81,5 +81,38 @@ export const createInMemoryUserRepository = ( } throw new Error('User not found.'); }, + async updateRoles(userId: string, roles: string[]): Promise { + 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 { + 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 { + 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.'); + }, }; }; diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index fe5f493..92ce4cf 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -129,5 +129,26 @@ export const createPostgresUserRepository = ( }, }); }, + async updateRoles(userId: string, roles: string[]): Promise { + await prisma.appUser.update({ + where: { id: userId }, + data: { + roles: roles as GatewayPrisma.JsonArray, + }, + }); + }, + async updateSanctions(userId: string, sanctions: UserSanctions): Promise { + await prisma.appUser.update({ + where: { id: userId }, + data: { + sanctions: sanctions as GatewayPrisma.JsonObject, + }, + }); + }, + async deleteUser(userId: string): Promise { + await prisma.appUser.delete({ + where: { id: userId }, + }); + }, }; }; diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 95287d1..b0cfa66 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -28,6 +28,15 @@ export interface UserSanctions { warningCount?: number; flags?: string[]; notes?: string; + profileIconResetAt?: string; + serverRestrictions?: Record; +} + +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; updatePassword(userId: string, password: string): Promise; updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise; + updateRoles(userId: string, roles: string[]): Promise; + updateSanctions(userId: string, sanctions: UserSanctions): Promise; + deleteUser(userId: string): Promise; } export interface UserOAuthInfo { diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index f3487f3..ba89bf7 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -81,6 +81,10 @@ export interface GatewayProfileRepository { lastUsedAt?: string | null; } ): Promise; + updateMeta( + profileName: string, + meta: Record + ): Promise; listReservedToStart(now: Date): Promise; findQueuedBuild(): Promise; updateLastError(profileName: string, lastError: string | null): Promise; @@ -303,6 +307,19 @@ export const createGatewayProfileRepository = ( }); return row ? mapProfile(row) : null; }, + async updateMeta( + profileName: string, + meta: Record + ): Promise { + 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 { const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const rows = await gatewayProfile.findMany({ diff --git a/app/gateway-frontend/src/layouts/DefaultLayout.vue b/app/gateway-frontend/src/layouts/DefaultLayout.vue index 0124d22..7ef3a41 100644 --- a/app/gateway-frontend/src/layouts/DefaultLayout.vue +++ b/app/gateway-frontend/src/layouts/DefaultLayout.vue @@ -17,6 +17,7 @@ 패치 내역 Git Repo. 위키 + 관리자
diff --git a/app/gateway-frontend/src/router/index.ts b/app/gateway-frontend/src/router/index.ts index 531ff4f..cab32d7 100644 --- a/app/gateway-frontend/src/router/index.ts +++ b/app/gateway-frontend/src/router/index.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import HomeView from '../views/HomeView.vue'; import LobbyView from '../views/LobbyView.vue'; +import AdminView from '../views/AdminView.vue'; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -15,6 +16,11 @@ const router = createRouter({ name: 'lobby', component: LobbyView, }, + { + path: '/admin', + name: 'admin', + component: AdminView, + }, ], }); diff --git a/app/gateway-frontend/src/utils/trpc.ts b/app/gateway-frontend/src/utils/trpc.ts index 804696f..99c0b9c 100644 --- a/app/gateway-frontend/src/utils/trpc.ts +++ b/app/gateway-frontend/src/utils/trpc.ts @@ -1,10 +1,21 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from '../../../gateway-api/src/router'; +const getAdminToken = (): string | null => { + if (typeof window === 'undefined') { + return null; + } + return window.localStorage.getItem('sammo-admin-token'); +}; + export const trpc = createTRPCProxyClient({ links: [ httpBatchLink({ url: '/api/trpc', // 실제 환경에 맞게 조정 필요 + headers() { + const token = getAdminToken(); + return token ? { 'x-admin-token': token } : {}; + }, }), ], }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue new file mode 100644 index 0000000..3d43307 --- /dev/null +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -0,0 +1,978 @@ + + +