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 { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
|
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||||
import {
|
import {
|
||||||
GATEWAY_BUILD_STATUSES,
|
GATEWAY_BUILD_STATUSES,
|
||||||
GATEWAY_PROFILE_STATUSES,
|
GATEWAY_PROFILE_STATUSES,
|
||||||
@@ -9,6 +12,17 @@ import {
|
|||||||
|
|
||||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||||
const zBuildStatus = z.enum(GATEWAY_BUILD_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 }) => {
|
const adminProcedure = procedure.use(({ ctx, next }) => {
|
||||||
if (!ctx.adminToken) {
|
if (!ctx.adminToken) {
|
||||||
@@ -32,7 +46,328 @@ const adminProcedure = procedure.use(({ ctx, next }) => {
|
|||||||
return 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({
|
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({
|
profiles: router({
|
||||||
list: adminProcedure.query(async ({ ctx }) => {
|
list: adminProcedure.query(async ({ ctx }) => {
|
||||||
const profiles = await ctx.profiles.listProfiles();
|
const profiles = await ctx.profiles.listProfiles();
|
||||||
@@ -113,6 +448,95 @@ export const adminRouter = router({
|
|||||||
await ctx.orchestrator.reconcileNow();
|
await ctx.orchestrator.reconcileNow();
|
||||||
return result;
|
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
|
requestBuild: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -81,5 +81,38 @@ export const createInMemoryUserRepository = (
|
|||||||
}
|
}
|
||||||
throw new Error('User not found.');
|
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;
|
warningCount?: number;
|
||||||
flags?: string[];
|
flags?: string[];
|
||||||
notes?: 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 => ({
|
export const toPublicUser = (user: UserRecord): PublicUser => ({
|
||||||
@@ -59,6 +68,9 @@ export interface UserRepository {
|
|||||||
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
|
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
|
||||||
updatePassword(userId: string, password: string): Promise<void>;
|
updatePassword(userId: string, password: string): Promise<void>;
|
||||||
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): 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 {
|
export interface UserOAuthInfo {
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export interface GatewayProfileRepository {
|
|||||||
lastUsedAt?: string | null;
|
lastUsedAt?: string | null;
|
||||||
}
|
}
|
||||||
): Promise<GatewayProfileRecord | null>;
|
): Promise<GatewayProfileRecord | null>;
|
||||||
|
updateMeta(
|
||||||
|
profileName: string,
|
||||||
|
meta: Record<string, unknown>
|
||||||
|
): Promise<GatewayProfileRecord | null>;
|
||||||
listReservedToStart(now: Date): Promise<GatewayProfileRecord[]>;
|
listReservedToStart(now: Date): Promise<GatewayProfileRecord[]>;
|
||||||
findQueuedBuild(): Promise<GatewayProfileRecord | null>;
|
findQueuedBuild(): Promise<GatewayProfileRecord | null>;
|
||||||
updateLastError(profileName: string, lastError: string | null): Promise<void>;
|
updateLastError(profileName: string, lastError: string | null): Promise<void>;
|
||||||
@@ -303,6 +307,19 @@ export const createGatewayProfileRepository = (
|
|||||||
});
|
});
|
||||||
return row ? mapProfile(row) : null;
|
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[]> {
|
async listReservedToStart(now: Date): Promise<GatewayProfileRecord[]> {
|
||||||
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient;
|
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient;
|
||||||
const rows = await gatewayProfile.findMany({
|
const rows = await gatewayProfile.findMany({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<a href="#" class="hover:text-white">패치 내역</a>
|
<a href="#" class="hover:text-white">패치 내역</a>
|
||||||
<a href="#" class="hover:text-white">Git Repo.</a>
|
<a href="#" class="hover:text-white">Git Repo.</a>
|
||||||
<a href="#" class="hover:text-white">위키</a>
|
<a href="#" class="hover:text-white">위키</a>
|
||||||
|
<RouterLink to="/admin" class="hover:text-white">관리자</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-4 text-sm">
|
<div class="flex space-x-4 text-sm">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import HomeView from '../views/HomeView.vue';
|
import HomeView from '../views/HomeView.vue';
|
||||||
import LobbyView from '../views/LobbyView.vue';
|
import LobbyView from '../views/LobbyView.vue';
|
||||||
|
import AdminView from '../views/AdminView.vue';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -15,6 +16,11 @@ const router = createRouter({
|
|||||||
name: 'lobby',
|
name: 'lobby',
|
||||||
component: LobbyView,
|
component: LobbyView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
name: 'admin',
|
||||||
|
component: AdminView,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||||
import type { AppRouter } from '../../../gateway-api/src/router';
|
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<AppRouter>({
|
export const trpc = createTRPCProxyClient<AppRouter>({
|
||||||
links: [
|
links: [
|
||||||
httpBatchLink({
|
httpBatchLink({
|
||||||
url: '/api/trpc', // 실제 환경에 맞게 조정 필요
|
url: '/api/trpc', // 실제 환경에 맞게 조정 필요
|
||||||
|
headers() {
|
||||||
|
const token = getAdminToken();
|
||||||
|
return token ? { 'x-admin-token': token } : {};
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,978 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
type AdminUserSanctions = {
|
||||||
|
bannedUntil?: string;
|
||||||
|
mutedUntil?: string;
|
||||||
|
suspendedUntil?: string;
|
||||||
|
warningCount?: number;
|
||||||
|
flags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
profileIconResetAt?: string;
|
||||||
|
serverRestrictions?: Record<string, {
|
||||||
|
blockedFeatures?: string[];
|
||||||
|
until?: string;
|
||||||
|
reason?: string;
|
||||||
|
notes?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminSanctionsPatch = {
|
||||||
|
bannedUntil?: string | null;
|
||||||
|
mutedUntil?: string | null;
|
||||||
|
suspendedUntil?: string | null;
|
||||||
|
warningCount?: number | null;
|
||||||
|
flags?: string[] | null;
|
||||||
|
notes?: string | null;
|
||||||
|
profileIconResetAt?: string | null;
|
||||||
|
serverRestrictions?: Record<string, {
|
||||||
|
blockedFeatures?: string[];
|
||||||
|
until?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
} | null> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
roles: string[];
|
||||||
|
sanctions: AdminUserSanctions;
|
||||||
|
oauthType: string;
|
||||||
|
oauthId?: string;
|
||||||
|
email?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminProfile = {
|
||||||
|
profileName: string;
|
||||||
|
profile: string;
|
||||||
|
scenario: string;
|
||||||
|
status: string;
|
||||||
|
apiPort: number;
|
||||||
|
runtime: {
|
||||||
|
apiRunning: boolean;
|
||||||
|
daemonRunning: boolean;
|
||||||
|
};
|
||||||
|
buildCommitSha?: string;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminAction =
|
||||||
|
| 'RESUME'
|
||||||
|
| 'PAUSE'
|
||||||
|
| 'STOP'
|
||||||
|
| 'ACCELERATE'
|
||||||
|
| 'DELAY'
|
||||||
|
| 'RESET_NOW'
|
||||||
|
| 'RESET_SCHEDULED'
|
||||||
|
| 'SHUTDOWN';
|
||||||
|
|
||||||
|
type AdminClient = {
|
||||||
|
system: {
|
||||||
|
getNotice: {
|
||||||
|
query: () => Promise<{ notice: string }>;
|
||||||
|
};
|
||||||
|
setNotice: {
|
||||||
|
mutate: (input: { notice: string }) => Promise<{ notice: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
users: {
|
||||||
|
lookup: {
|
||||||
|
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
|
||||||
|
};
|
||||||
|
resetPassword: {
|
||||||
|
mutate: (input: { userId: string; newPassword?: string }) => Promise<{ password: string }>;
|
||||||
|
};
|
||||||
|
updateRoles: {
|
||||||
|
mutate: (input: { userId: string; roles: string[]; mode?: 'set' | 'grant' | 'revoke' }) => Promise<{ roles: string[] }>;
|
||||||
|
};
|
||||||
|
updateSanctions: {
|
||||||
|
mutate: (input: { userId: string; patch: AdminSanctionsPatch }) => Promise<{ sanctions: AdminUserSanctions }>;
|
||||||
|
};
|
||||||
|
setServerRestriction: {
|
||||||
|
mutate: (input: {
|
||||||
|
userId: string;
|
||||||
|
profile: string;
|
||||||
|
restriction: {
|
||||||
|
blockedFeatures?: string[];
|
||||||
|
until?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
} | null;
|
||||||
|
}) => Promise<{ sanctions: AdminUserSanctions }>;
|
||||||
|
};
|
||||||
|
resetProfileIcon: {
|
||||||
|
mutate: (input: { userId: string }) => Promise<{ profileIconResetAt?: string }>;
|
||||||
|
};
|
||||||
|
forceDelete: {
|
||||||
|
mutate: (input: { userId: string }) => Promise<{ ok: boolean }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
profiles: {
|
||||||
|
list: {
|
||||||
|
query: () => Promise<AdminProfile[]>;
|
||||||
|
};
|
||||||
|
updateMeta: {
|
||||||
|
mutate: (input: {
|
||||||
|
profileName: string;
|
||||||
|
patch: {
|
||||||
|
korName?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
inGameNotice?: string | null;
|
||||||
|
profileImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
}) => Promise<AdminProfile | null>;
|
||||||
|
};
|
||||||
|
requestAction: {
|
||||||
|
mutate: (input: {
|
||||||
|
profileName: string;
|
||||||
|
action: AdminAction;
|
||||||
|
durationMinutes?: number;
|
||||||
|
scheduledAt?: string;
|
||||||
|
reason?: string;
|
||||||
|
}) => Promise<{ ok: boolean }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminClient = trpc.admin as unknown as AdminClient;
|
||||||
|
|
||||||
|
const adminToken = ref('');
|
||||||
|
const adminTokenStatus = ref('');
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
adminToken.value = window.localStorage.getItem('sammo-admin-token') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAdminToken = () => {
|
||||||
|
const value = adminToken.value.trim();
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (value) {
|
||||||
|
window.localStorage.setItem('sammo-admin-token', value);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem('sammo-admin-token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adminTokenStatus.value = value ? '저장됨' : '삭제됨';
|
||||||
|
};
|
||||||
|
|
||||||
|
const noticeDraft = ref('');
|
||||||
|
const noticeStatus = ref('');
|
||||||
|
const noticeLoading = ref(false);
|
||||||
|
|
||||||
|
const profiles = ref<AdminProfile[]>([]);
|
||||||
|
const profilesLoading = ref(false);
|
||||||
|
const profileEdits = ref<Record<string, {
|
||||||
|
korName: string;
|
||||||
|
color: string;
|
||||||
|
inGameNotice: string;
|
||||||
|
profileImageUrl: string;
|
||||||
|
}>>({});
|
||||||
|
const profileActions = ref<Record<string, {
|
||||||
|
durationMinutes: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
reason: string;
|
||||||
|
}>>({});
|
||||||
|
const profileActionStatus = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
|
||||||
|
const userLookupValue = ref('');
|
||||||
|
const userLoading = ref(false);
|
||||||
|
const userError = ref('');
|
||||||
|
const userResult = ref<AdminUser | null>(null);
|
||||||
|
|
||||||
|
const passwordInput = ref('');
|
||||||
|
const passwordResult = ref('');
|
||||||
|
const passwordStatus = ref('');
|
||||||
|
|
||||||
|
const rolesInput = ref('');
|
||||||
|
const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant');
|
||||||
|
const rolesStatus = ref('');
|
||||||
|
|
||||||
|
const banUntil = ref('');
|
||||||
|
const banReason = ref('');
|
||||||
|
const banStatus = ref('');
|
||||||
|
|
||||||
|
const profileIconStatus = ref('');
|
||||||
|
|
||||||
|
const restrictionProfile = ref('');
|
||||||
|
const restrictionFeatures = ref('');
|
||||||
|
const restrictionUntil = ref('');
|
||||||
|
const restrictionReason = ref('');
|
||||||
|
const restrictionNotes = ref('');
|
||||||
|
const restrictionStatus = ref('');
|
||||||
|
|
||||||
|
const forceDeleteStatus = ref('');
|
||||||
|
|
||||||
|
const hasUser = computed(() => Boolean(userResult.value));
|
||||||
|
|
||||||
|
const loadNotice = async () => {
|
||||||
|
noticeLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await adminClient.system.getNotice.query();
|
||||||
|
noticeDraft.value = result.notice;
|
||||||
|
} catch (error) {
|
||||||
|
noticeStatus.value = '공지 불러오기 실패';
|
||||||
|
} finally {
|
||||||
|
noticeLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveNotice = async () => {
|
||||||
|
noticeLoading.value = true;
|
||||||
|
noticeStatus.value = '';
|
||||||
|
try {
|
||||||
|
const result = await adminClient.system.setNotice.mutate({
|
||||||
|
notice: noticeDraft.value,
|
||||||
|
});
|
||||||
|
noticeDraft.value = result.notice;
|
||||||
|
noticeStatus.value = '저장 완료';
|
||||||
|
} catch (error) {
|
||||||
|
noticeStatus.value = '저장 실패';
|
||||||
|
} finally {
|
||||||
|
noticeLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureProfileBuffers = (profile: AdminProfile) => {
|
||||||
|
if (!profileEdits.value[profile.profileName]) {
|
||||||
|
const meta = (profile.meta ?? {}) as Record<string, unknown>;
|
||||||
|
profileEdits.value[profile.profileName] = {
|
||||||
|
korName: String(meta.korName ?? profile.profile),
|
||||||
|
color: String(meta.color ?? '#ffffff'),
|
||||||
|
inGameNotice: String(meta.inGameNotice ?? ''),
|
||||||
|
profileImageUrl: String(meta.profileImageUrl ?? ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!profileActions.value[profile.profileName]) {
|
||||||
|
profileActions.value[profile.profileName] = {
|
||||||
|
durationMinutes: '',
|
||||||
|
scheduledAt: '',
|
||||||
|
reason: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProfiles = async () => {
|
||||||
|
profilesLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await adminClient.profiles.list.query();
|
||||||
|
result.forEach(ensureProfileBuffers);
|
||||||
|
profiles.value = result;
|
||||||
|
} catch (error) {
|
||||||
|
profileActionStatus.value = {
|
||||||
|
...profileActionStatus.value,
|
||||||
|
global: '프로필 목록을 불러오지 못했습니다.',
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
profilesLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProfileMeta = async (profileName: string) => {
|
||||||
|
const edit = profileEdits.value[profileName];
|
||||||
|
if (!edit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patch = {
|
||||||
|
korName: edit.korName.trim() || null,
|
||||||
|
color: edit.color.trim() || null,
|
||||||
|
inGameNotice: edit.inGameNotice.trim() || null,
|
||||||
|
profileImageUrl: edit.profileImageUrl.trim() || null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const updated = await adminClient.profiles.updateMeta.mutate({
|
||||||
|
profileName,
|
||||||
|
patch,
|
||||||
|
});
|
||||||
|
profileActionStatus.value = {
|
||||||
|
...profileActionStatus.value,
|
||||||
|
[profileName]: updated ? '메타 저장 완료' : '메타 저장 실패',
|
||||||
|
};
|
||||||
|
if (updated) {
|
||||||
|
profiles.value = profiles.value.map((item) =>
|
||||||
|
item.profileName === profileName ? { ...item, ...updated } : item
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
profileActionStatus.value = {
|
||||||
|
...profileActionStatus.value,
|
||||||
|
[profileName]: '메타 저장 실패',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestProfileAction = async (profileName: string, action: AdminAction) => {
|
||||||
|
const actionState = profileActions.value[profileName];
|
||||||
|
const durationMinutes = actionState?.durationMinutes
|
||||||
|
? Number(actionState.durationMinutes)
|
||||||
|
: undefined;
|
||||||
|
const durationValue =
|
||||||
|
durationMinutes && durationMinutes > 0 ? durationMinutes : undefined;
|
||||||
|
const scheduledAt = actionState?.scheduledAt
|
||||||
|
? new Date(actionState.scheduledAt).toISOString()
|
||||||
|
: undefined;
|
||||||
|
const reason = actionState?.reason.trim() || undefined;
|
||||||
|
try {
|
||||||
|
await adminClient.profiles.requestAction.mutate({
|
||||||
|
profileName,
|
||||||
|
action,
|
||||||
|
durationMinutes: durationValue,
|
||||||
|
scheduledAt,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
profileActionStatus.value = {
|
||||||
|
...profileActionStatus.value,
|
||||||
|
[profileName]: `요청 완료: ${action}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
profileActionStatus.value = {
|
||||||
|
...profileActionStatus.value,
|
||||||
|
[profileName]: `요청 실패: ${action}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lookupUser = async () => {
|
||||||
|
userLoading.value = true;
|
||||||
|
userError.value = '';
|
||||||
|
passwordStatus.value = '';
|
||||||
|
rolesStatus.value = '';
|
||||||
|
banStatus.value = '';
|
||||||
|
restrictionStatus.value = '';
|
||||||
|
profileIconStatus.value = '';
|
||||||
|
forceDeleteStatus.value = '';
|
||||||
|
try {
|
||||||
|
const payload =
|
||||||
|
userLookupMode.value === 'id'
|
||||||
|
? { id: userLookupValue.value.trim() }
|
||||||
|
: userLookupMode.value === 'email'
|
||||||
|
? { email: userLookupValue.value.trim() }
|
||||||
|
: { username: userLookupValue.value.trim() };
|
||||||
|
const result = await adminClient.users.lookup.query(payload);
|
||||||
|
if (!result) {
|
||||||
|
userResult.value = null;
|
||||||
|
userError.value = '사용자를 찾을 수 없습니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userResult.value = result;
|
||||||
|
} catch (error) {
|
||||||
|
userError.value = '조회 실패';
|
||||||
|
} finally {
|
||||||
|
userLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetUserPassword = async () => {
|
||||||
|
if (!userResult.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
passwordStatus.value = '';
|
||||||
|
passwordResult.value = '';
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.resetPassword.mutate({
|
||||||
|
userId: userResult.value.id,
|
||||||
|
newPassword: passwordInput.value.trim() || undefined,
|
||||||
|
});
|
||||||
|
passwordResult.value = result.password;
|
||||||
|
passwordStatus.value = '초기화 완료';
|
||||||
|
passwordInput.value = '';
|
||||||
|
} catch (error) {
|
||||||
|
passwordStatus.value = '초기화 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUserRoles = async () => {
|
||||||
|
if (!userResult.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roles = rolesInput.value
|
||||||
|
.split(',')
|
||||||
|
.map((role) => role.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!roles.length) {
|
||||||
|
rolesStatus.value = '역할을 입력하세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rolesStatus.value = '';
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.updateRoles.mutate({
|
||||||
|
userId: userResult.value.id,
|
||||||
|
roles,
|
||||||
|
mode: rolesMode.value,
|
||||||
|
});
|
||||||
|
userResult.value = { ...userResult.value, roles: result.roles };
|
||||||
|
rolesStatus.value = '권한 업데이트 완료';
|
||||||
|
} catch (error) {
|
||||||
|
rolesStatus.value = '권한 업데이트 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyBan = async () => {
|
||||||
|
if (!userResult.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
|
||||||
|
const patch = {
|
||||||
|
bannedUntil: until,
|
||||||
|
notes: banReason.value.trim() || undefined,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.updateSanctions.mutate({
|
||||||
|
userId: userResult.value.id,
|
||||||
|
patch,
|
||||||
|
});
|
||||||
|
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
||||||
|
banStatus.value = '차단 설정 완료';
|
||||||
|
} catch (error) {
|
||||||
|
banStatus.value = '차단 설정 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearBan = async () => {
|
||||||
|
if (!userResult.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.updateSanctions.mutate({
|
||||||
|
userId: userResult.value.id,
|
||||||
|
patch: { bannedUntil: null },
|
||||||
|
});
|
||||||
|
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
||||||
|
banStatus.value = '차단 해제 완료';
|
||||||
|
} catch (error) {
|
||||||
|
banStatus.value = '차단 해제 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetProfileIcon = async () => {
|
||||||
|
if (!userResult.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.resetProfileIcon.mutate({
|
||||||
|
userId: userResult.value.id,
|
||||||
|
});
|
||||||
|
userResult.value = {
|
||||||
|
...userResult.value,
|
||||||
|
sanctions: {
|
||||||
|
...userResult.value.sanctions,
|
||||||
|
profileIconResetAt: result.profileIconResetAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
profileIconStatus.value = '아이콘 초기화 요청 완료';
|
||||||
|
} catch (error) {
|
||||||
|
profileIconStatus.value = '아이콘 초기화 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRestriction = async () => {
|
||||||
|
if (!userResult.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!restrictionProfile.value.trim()) {
|
||||||
|
restrictionStatus.value = '서버 프로필명을 입력하세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const features = restrictionFeatures.value
|
||||||
|
.split(',')
|
||||||
|
.map((feature) => feature.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const restriction = {
|
||||||
|
blockedFeatures: features.length ? features : undefined,
|
||||||
|
until: restrictionUntil.value ? new Date(restrictionUntil.value).toISOString() : undefined,
|
||||||
|
reason: restrictionReason.value.trim() || undefined,
|
||||||
|
notes: restrictionNotes.value.trim() || undefined,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.setServerRestriction.mutate({
|
||||||
|
userId: userResult.value.id,
|
||||||
|
profile: restrictionProfile.value.trim(),
|
||||||
|
restriction,
|
||||||
|
});
|
||||||
|
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
||||||
|
restrictionStatus.value = '서버 제재 적용 완료';
|
||||||
|
} catch (error) {
|
||||||
|
restrictionStatus.value = '서버 제재 적용 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearRestriction = async () => {
|
||||||
|
if (!userResult.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!restrictionProfile.value.trim()) {
|
||||||
|
restrictionStatus.value = '서버 프로필명을 입력하세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await adminClient.users.setServerRestriction.mutate({
|
||||||
|
userId: userResult.value.id,
|
||||||
|
profile: restrictionProfile.value.trim(),
|
||||||
|
restriction: null,
|
||||||
|
});
|
||||||
|
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
||||||
|
restrictionStatus.value = '서버 제재 해제 완료';
|
||||||
|
} catch (error) {
|
||||||
|
restrictionStatus.value = '서버 제재 해제 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forceDeleteUser = async () => {
|
||||||
|
if (!userResult.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const confirmed = window.confirm('정말로 강제 탈퇴 처리하시겠습니까?');
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminClient.users.forceDelete.mutate({ userId: userResult.value.id });
|
||||||
|
userResult.value = null;
|
||||||
|
forceDeleteStatus.value = '강제 탈퇴 완료';
|
||||||
|
} catch (error) {
|
||||||
|
forceDeleteStatus.value = '강제 탈퇴 실패';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadNotice();
|
||||||
|
void loadProfiles();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DefaultLayout>
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
|
||||||
|
<p class="text-sm text-zinc-400">
|
||||||
|
유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold">관리자 토큰</h3>
|
||||||
|
<span class="text-xs text-zinc-500">{{ adminTokenStatus }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col md:flex-row gap-3">
|
||||||
|
<input
|
||||||
|
v-model="adminToken"
|
||||||
|
type="password"
|
||||||
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
||||||
|
placeholder="GATEWAY_ADMIN_TOKEN 입력"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-4 py-2 rounded"
|
||||||
|
@click="saveAdminToken"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-2 gap-8">
|
||||||
|
<section class="space-y-6">
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold">유저 관리</h3>
|
||||||
|
<form class="space-y-3" @submit.prevent="lookupUser">
|
||||||
|
<div class="flex flex-col md:flex-row gap-2">
|
||||||
|
<select
|
||||||
|
v-model="userLookupMode"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option value="username">계정명</option>
|
||||||
|
<option value="email">이메일</option>
|
||||||
|
<option value="id">UUID</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-model="userLookupValue"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
||||||
|
placeholder="검색 값 입력"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="userLoading"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="userError" class="text-xs text-red-400">{{ userError }}</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="userResult" class="bg-zinc-950 border border-zinc-800 rounded p-4 space-y-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="text-sm font-semibold">{{ userResult.username }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ userResult.id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-400">표시명: {{ userResult.displayName }}</div>
|
||||||
|
<div class="text-xs text-zinc-400">권한: {{ userResult.roles.join(', ') || '-' }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
|
||||||
|
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
|
||||||
|
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap">
|
||||||
|
{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
|
||||||
|
<div class="flex flex-col md:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
v-model="passwordInput"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="직접 지정 시 입력"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
@click="resetUserPassword"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-400">임시 비밀번호: {{ passwordResult || '-' }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ passwordStatus }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h4 class="text-base font-semibold">특수 권한 부여</h4>
|
||||||
|
<div class="flex flex-col md:flex-row gap-2">
|
||||||
|
<select
|
||||||
|
v-model="rolesMode"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
>
|
||||||
|
<option value="grant">추가</option>
|
||||||
|
<option value="revoke">제거</option>
|
||||||
|
<option value="set">덮어쓰기</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-model="rolesInput"
|
||||||
|
type="text"
|
||||||
|
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="admin, moderator 등"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
@click="updateUserRoles"
|
||||||
|
>
|
||||||
|
적용
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h4 class="text-base font-semibold">유저 차단</h4>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
v-model="banUntil"
|
||||||
|
type="datetime-local"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="banReason"
|
||||||
|
type="text"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="사유"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
@click="applyBan"
|
||||||
|
>
|
||||||
|
차단 설정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
@click="clearBan"
|
||||||
|
>
|
||||||
|
차단 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ banStatus }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h4 class="text-base font-semibold">서버별 기능 제재</h4>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<input
|
||||||
|
v-model="restrictionProfile"
|
||||||
|
type="text"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="profile:scenario"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="restrictionFeatures"
|
||||||
|
type="text"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="login, message 등"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="restrictionUntil"
|
||||||
|
type="datetime-local"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="restrictionReason"
|
||||||
|
type="text"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="사유"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="restrictionNotes"
|
||||||
|
type="text"
|
||||||
|
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="메모"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
@click="applyRestriction"
|
||||||
|
>
|
||||||
|
제재 적용
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
@click="clearRestriction"
|
||||||
|
>
|
||||||
|
제재 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ restrictionStatus }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h4 class="text-base font-semibold">프로필 아이콘 초기화</h4>
|
||||||
|
<button
|
||||||
|
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
@click="resetProfileIcon"
|
||||||
|
>
|
||||||
|
아이콘 초기화 요청
|
||||||
|
</button>
|
||||||
|
<div class="text-xs text-zinc-500">{{ profileIconStatus }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<h4 class="text-base font-semibold text-red-400">강제 탈퇴</h4>
|
||||||
|
<button
|
||||||
|
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="!hasUser"
|
||||||
|
@click="forceDeleteUser"
|
||||||
|
>
|
||||||
|
강제 탈퇴 처리
|
||||||
|
</button>
|
||||||
|
<div class="text-xs text-zinc-500">{{ forceDeleteStatus }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-6">
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold">서버 공지</h3>
|
||||||
|
<span class="text-xs text-zinc-500">{{ noticeStatus }}</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="noticeDraft"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-sm text-white min-h-[140px]"
|
||||||
|
placeholder="로비 공지 입력"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="noticeLoading"
|
||||||
|
@click="saveNotice"
|
||||||
|
>
|
||||||
|
공지 저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
|
||||||
|
:disabled="noticeLoading"
|
||||||
|
@click="loadNotice"
|
||||||
|
>
|
||||||
|
다시 불러오기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold">서버별 관리</h3>
|
||||||
|
<button
|
||||||
|
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
|
||||||
|
:disabled="profilesLoading"
|
||||||
|
@click="loadProfiles"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="profile in profiles"
|
||||||
|
:key="profile.profileName"
|
||||||
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-semibold">
|
||||||
|
{{ profile.profileName }} ({{ profile.profile }})
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-400">
|
||||||
|
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
||||||
|
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-zinc-400">
|
||||||
|
빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs text-zinc-400">표시명</label>
|
||||||
|
<input
|
||||||
|
v-model="profileEdits[profile.profileName].korName"
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
<label class="text-xs text-zinc-400">색상</label>
|
||||||
|
<input
|
||||||
|
v-model="profileEdits[profile.profileName].color"
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
<label class="text-xs text-zinc-400">인게임 공지</label>
|
||||||
|
<textarea
|
||||||
|
v-model="profileEdits[profile.profileName].inGameNotice"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded p-2 text-sm text-white min-h-[80px]"
|
||||||
|
/>
|
||||||
|
<label class="text-xs text-zinc-400">프로필 이미지</label>
|
||||||
|
<input
|
||||||
|
v-model="profileEdits[profile.profileName].profileImageUrl"
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
|
||||||
|
@click="updateProfileMeta(profile.profileName)"
|
||||||
|
>
|
||||||
|
메타 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs text-zinc-400">특수 동작 메모</label>
|
||||||
|
<input
|
||||||
|
v-model="profileActions[profile.profileName].reason"
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
placeholder="사유 / 메모"
|
||||||
|
/>
|
||||||
|
<label class="text-xs text-zinc-400">가속/연기 (분)</label>
|
||||||
|
<input
|
||||||
|
v-model="profileActions[profile.profileName].durationMinutes"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="1440"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
<label class="text-xs text-zinc-400">리셋 예약</label>
|
||||||
|
<input
|
||||||
|
v-model="profileActions[profile.profileName].scheduledAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'RESUME')"
|
||||||
|
>
|
||||||
|
재개
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'PAUSE')"
|
||||||
|
>
|
||||||
|
일시정지
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'STOP')"
|
||||||
|
>
|
||||||
|
중지
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-orange-600 hover:bg-orange-500 text-black font-semibold px-3 py-2 rounded"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'ACCELERATE')"
|
||||||
|
>
|
||||||
|
가속
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-3 py-2 rounded"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'DELAY')"
|
||||||
|
>
|
||||||
|
연기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-3 py-2 rounded"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'RESET_NOW')"
|
||||||
|
>
|
||||||
|
즉시 리셋
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-purple-800 hover:bg-purple-700 text-white font-semibold px-3 py-2 rounded"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'RESET_SCHEDULED')"
|
||||||
|
>
|
||||||
|
리셋 예약
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-black hover:bg-zinc-800 text-white font-semibold px-3 py-2 rounded col-span-2"
|
||||||
|
@click="requestProfileAction(profile.profileName, 'SHUTDOWN')"
|
||||||
|
>
|
||||||
|
서버 폐쇄
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
{{ profileActionStatus[profile.profileName] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
|
||||||
|
{{ profileActionStatus.global }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,15 @@ export interface UserSanctions {
|
|||||||
warningCount?: number;
|
warningCount?: number;
|
||||||
flags?: string[];
|
flags?: string[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
profileIconResetAt?: string;
|
||||||
|
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserServerRestriction {
|
||||||
|
blockedFeatures?: string[];
|
||||||
|
until?: string;
|
||||||
|
reason?: string;
|
||||||
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayUserInfo {
|
export interface GatewayUserInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user