Merge branch 'main' into feature/admin-navigation-20260808
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user