Merge branch 'main' into feature/admin-navigation-20260808

This commit is contained in:
2026-08-08 17:10:52 +00:00
11 changed files with 579 additions and 63 deletions
+18 -2
View File
@@ -20,6 +20,7 @@ import {
import type { GatewayApiContext } from './context.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
import { orderGatewayProfiles } from './profileOrder.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
@@ -399,6 +400,14 @@ const zUserLookupInput = z
message: 'id, username, or email must be provided.',
});
const zUserListInput = z
.object({
query: z.string().trim().max(100).optional(),
limit: z.number().int().min(1).max(100).default(30),
cursor: z.string().uuid().optional(),
})
.optional();
const zServerRestriction = z.object({
blockedFeatures: z.array(z.string().min(1)).optional(),
until: z.string().datetime().nullable().optional(),
@@ -610,6 +619,13 @@ export const adminRouter = router({
getLocalAccountStatus: adminProcedure.query(({ ctx }) => ({
enabled: (ctx as GatewayApiContext).adminLocalAccountEnabled,
})),
list: userAdminProcedure.input(zUserListInput).query(({ ctx, input }) =>
ctx.users.listForAdmin({
query: input?.query,
limit: input?.limit ?? 30,
cursor: input?.cursor,
})
),
createLocal: userCreateProcedure.input(zLocalAccountInput).mutation(async ({ ctx, input }) => {
const gatewayCtx = ctx as GatewayApiContext;
assertLocalAccountEnabled(gatewayCtx);
@@ -670,7 +686,7 @@ export const adminRouter = router({
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
const profiles = await ctx.profiles.listProfiles();
const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles());
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
return {
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
@@ -1430,7 +1446,7 @@ export const adminRouter = router({
profiles: router({
list: adminProcedure.query(async ({ ctx }) => {
const adminAuth = requireAdminAuth(ctx);
const profiles = (await ctx.profiles.listProfiles()).filter((profile) =>
const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()).filter((profile) =>
canReadProfile(adminAuth, profile.profileName)
);
const profileNames = profiles.map((profile) => profile.profileName);
@@ -2,12 +2,26 @@ import { randomUUID } from 'node:crypto';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
AdminUserListItem,
CreateUserInput,
SpecialAccountAccessGrantRecord,
UserIconRecord,
UserRecord,
UserRepository,
} from './userRepository.js';
import { hasActiveUserSanction } from './userRepository.js';
const toAdminUserListItem = (user: UserRecord): AdminUserListItem => ({
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
oauthType: user.oauthType,
roles: [...user.roles],
hasActiveSanction: hasActiveUserSanction(user.sanctions),
deleteAfter: user.deleteAfter,
createdAt: user.createdAt,
});
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
@@ -56,6 +70,31 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
async findByEmail(email: string): Promise<UserRecord | null> {
return usersByEmail.get(email.toLowerCase()) ?? null;
},
async listForAdmin(input) {
const query = input.query?.trim().toLocaleLowerCase() ?? '';
const matching = [...usersByName.values()]
.filter((user) => {
if (!query) return true;
return [user.username, user.displayName, user.email ?? '', user.id].some((value) =>
value.toLocaleLowerCase().includes(query)
);
})
.sort((left, right) => {
const byCreatedAt = right.createdAt.localeCompare(left.createdAt);
return byCreatedAt === 0 ? right.id.localeCompare(left.id) : byCreatedAt;
});
const startIndex = input.cursor
? Math.max(0, matching.findIndex((user) => user.id === input.cursor) + 1)
: 0;
const page = matching.slice(startIndex, startIndex + input.limit + 1);
const hasNextPage = page.length > input.limit;
const users = page.slice(0, input.limit);
return {
users: users.map(toAdminUserListItem),
total: matching.length,
nextCursor: hasNextPage ? users.at(-1)?.id : undefined,
};
},
async createUser(input: CreateUserInput): Promise<UserRecord> {
if (usersByName.has(input.username)) {
throw new Error('User already exists.');
@@ -2,6 +2,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
AdminUserListItem,
CreateUserInput,
SpecialAccountAccessGrantRecord,
UserIconRecord,
@@ -10,6 +11,21 @@ import type {
UserRepository,
UserSanctions,
} from './userRepository.js';
import { hasActiveUserSanction } from './userRepository.js';
const toAdminUserListItem = (user: UserRecord): AdminUserListItem => {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
oauthType: user.oauthType,
roles: user.roles,
hasActiveSanction: hasActiveUserSanction(user.sanctions),
deleteAfter: user.deleteAfter,
createdAt: user.createdAt,
};
};
const readStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) {
@@ -195,6 +211,35 @@ export const createPostgresUserRepository = (
});
return row ? mapUser(row) : null;
},
async listForAdmin(input) {
const query = input.query?.trim();
const where = query
? {
OR: [
{ loginId: { contains: query, mode: 'insensitive' as const } },
{ displayName: { contains: query, mode: 'insensitive' as const } },
{ email: { contains: query, mode: 'insensitive' as const } },
{ id: { contains: query, mode: 'insensitive' as const } },
],
}
: undefined;
const [rows, total] = await Promise.all([
prisma.appUser.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: input.limit + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
}),
prisma.appUser.count({ where }),
]);
const hasNextPage = rows.length > input.limit;
const page = rows.slice(0, input.limit);
return {
users: page.map(mapUser).map(toAdminUserListItem),
total,
nextCursor: hasNextPage ? page.at(-1)?.id : undefined,
};
},
async createUser(input: CreateUserInput): Promise<UserRecord> {
const password = await hasher.hash(input.password);
const oauthType = input.oauth?.type ?? 'NONE';
@@ -73,6 +73,24 @@ export interface PublicUser {
createdAt: string;
}
export interface AdminUserListItem {
id: string;
username: string;
displayName: string;
email?: string;
oauthType: 'NONE' | 'KAKAO';
roles: string[];
hasActiveSanction: boolean;
deleteAfter?: string;
createdAt: string;
}
export interface AdminUserListResult {
users: AdminUserListItem[];
total: number;
nextCursor?: string;
}
export interface UserSanctions {
bannedUntil?: string;
mutedUntil?: string;
@@ -91,6 +109,19 @@ export interface UserServerRestriction {
notes?: string;
}
export const hasActiveUserSanction = (sanctions: UserSanctions, now = Date.now()): boolean => {
const hasActiveGlobalSanction = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil].some(
(value) => value !== undefined && new Date(value).getTime() > now
);
if (hasActiveGlobalSanction || (sanctions.flags?.length ?? 0) > 0) {
return true;
}
return Object.values(sanctions.serverRestrictions ?? {}).some((restriction) => {
if ((restriction.blockedFeatures?.length ?? 0) === 0) return false;
return restriction.until === undefined || new Date(restriction.until).getTime() > now;
});
};
export const toPublicUser = (user: UserRecord): PublicUser => ({
id: user.id,
username: user.username,
@@ -124,6 +155,7 @@ export interface UserRepository {
findByDisplayName(displayName: string): Promise<UserRecord | null>;
findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null>;
findByEmail(email: string): Promise<UserRecord | null>;
listForAdmin(input: { query?: string; limit: number; cursor?: string }): Promise<AdminUserListResult>;
createUser(input: CreateUserInput): Promise<UserRecord>;
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
updatePassword(userId: string, password: string): Promise<void>;
@@ -4,6 +4,7 @@ import type {
GatewayProfileRepository,
GatewayProfileStatus,
} from '../orchestrator/profileRepository.js';
import { orderGatewayProfiles } from '../profileOrder.js';
export type LobbyMapSnapshot = {
updatedAt: string | null;
@@ -47,7 +48,7 @@ export class InMemoryProfileStatusService implements GatewayProfileStatusService
}
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
return this.profiles;
return orderGatewayProfiles(this.profiles);
}
setProfiles(profiles: LobbyProfileStatus[]): void {
@@ -63,7 +64,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
) {}
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
const rows = await this.profiles.listProfiles();
const rows = orderGatewayProfiles(await this.profiles.listProfiles());
const runtimeStates = await this.orchestrator.listRuntimeStates(rows.map((profile) => profile.profileName));
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
return rows.map((row) => this.mapProfile(row, runtimeMap));
+21
View File
@@ -0,0 +1,21 @@
export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'] as const;
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
export const compareGatewayProfiles = (
left: { profile: string; scenario: string },
right: { profile: string; scenario: string }
): number => {
const unknownRank = GATEWAY_PROFILE_ORDER.length;
const profileOrder =
(gatewayProfileOrder.get(left.profile) ?? unknownRank) -
(gatewayProfileOrder.get(right.profile) ?? unknownRank);
if (profileOrder !== 0) return profileOrder;
const profileNameOrder = left.profile.localeCompare(right.profile);
if (profileNameOrder !== 0) return profileNameOrder;
return left.scenario.localeCompare(right.scenario);
};
export const orderGatewayProfiles = <T extends { profile: string; scenario: string }>(profiles: readonly T[]): T[] =>
[...profiles].sort(compareGatewayProfiles);
@@ -982,6 +982,39 @@ describe('Gateway administrator account controls', () => {
throw new Error('not used');
};
it('lists accounts before exact lookup and supports partial search with cursor pagination', async () => {
const { caller, users } = await buildCaller(unusedCreateOperation);
await users.createUser({
username: 'alpha-user',
password: 'secretpass',
displayName: 'Pilot Alpha',
});
await users.createUser({
username: 'kakao-user',
password: 'secretpass',
displayName: 'Kakao Member',
oauth: {
type: 'KAKAO',
id: 'kakao-directory-id',
email: 'pilot@example.test',
info: {},
},
});
const search = await caller.admin.users.list({ query: 'pilot', limit: 30 });
expect(search.total).toBe(2);
expect(search.users.map((user) => user.username).sort()).toEqual(['alpha-user', 'kakao-user']);
expect(search.users[0]).not.toHaveProperty('oauthId');
const firstPage = await caller.admin.users.list({ limit: 1 });
expect(firstPage.total).toBe(3);
expect(firstPage.users).toHaveLength(1);
expect(firstPage.nextCursor).toBeTruthy();
const secondPage = await caller.admin.users.list({ limit: 1, cursor: firstPage.nextCursor });
expect(secondPage.users).toHaveLength(1);
expect(secondPage.users[0]?.id).not.toBe(firstPage.users[0]?.id);
});
it('records sanitized STARTED and SUCCEEDED events and exposes target history', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { GATEWAY_PROFILE_ORDER, orderGatewayProfiles } from '../src/profileOrder.js';
describe('orderGatewayProfiles', () => {
it('uses the public server order instead of alphabetical profile order', () => {
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
profile,
scenario: 'default',
}));
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER);
});
it('orders scenarios within a profile and places unknown profiles afterward', () => {
const profiles = [
{ profile: 'zeta', scenario: 'default' },
{ profile: 'che', scenario: '20' },
{ profile: 'alpha', scenario: 'default' },
{ profile: 'che', scenario: '10' },
];
expect(orderGatewayProfiles(profiles)).toEqual([
{ profile: 'che', scenario: '10' },
{ profile: 'che', scenario: '20' },
{ profile: 'alpha', scenario: 'default' },
{ profile: 'zeta', scenario: 'default' },
]);
expect(profiles[0]?.profile).toBe('zeta');
});
});