feat(gateway): add admin user directory
This commit is contained in:
@@ -381,6 +381,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(),
|
||||
@@ -584,6 +592,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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -846,6 +846,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({
|
||||
@@ -954,10 +987,9 @@ describe('Gateway administrator account controls', () => {
|
||||
})
|
||||
).resolves.toMatchObject({ id: grant.id, revokedReason: 'Kakao 인증 수단 복구 완료' });
|
||||
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-revoked' });
|
||||
expect(harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)).toEqual([
|
||||
'admin.users.grantSpecialAccess',
|
||||
'admin.users.revokeSpecialAccess',
|
||||
]);
|
||||
expect(
|
||||
harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)
|
||||
).toEqual(['admin.users.grantSpecialAccess', 'admin.users.revokeSpecialAccess']);
|
||||
});
|
||||
|
||||
it('requires recovery access to expire within 90 days', async () => {
|
||||
|
||||
Reference in New Issue
Block a user