Add admin role appointment and status display
- POST /admin/users/admin 신설 — 활성 유저에게 custom claims(admin:true) 부여, 기존 claims 보존, 이미 관리자면 alreadyAdmin으로 멱등 응답 - 비활성 유저·미존재 유저는 INACTIVE_USER/USER_NOT_FOUND로 거부 - 유저 목록·검색 응답에 isAdmin 표시(auth.getUsers로 일괄 조회) - adminUserService 테스트 보강
This commit is contained in:
parent
9a66d3fb12
commit
d30ab16e9f
@ -12,7 +12,7 @@ import { Timestamp } from "firebase-admin/firestore";
|
||||
import type { OrderStatus, ProductDoc } from "../types/reward";
|
||||
import { transitionOrder, updateOrderShipment } from "../services/orderService";
|
||||
import { invalidateRewardCatalog } from "../services/rewardCatalogService";
|
||||
import { listAdminUsers, searchAdminUsers } from "../services/adminUserService";
|
||||
import { appointAdmin, listAdminUsers, searchAdminUsers } from "../services/adminUserService";
|
||||
import {
|
||||
toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto,
|
||||
type AdminProductDto, type LedgerPageDto, type OrderPageDto,
|
||||
@ -132,6 +132,11 @@ export const admin = onRequest(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tail === "users/admin" && req.method === "POST") {
|
||||
res.json(await appointAdmin(req.body?.uid, adminUid));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tail === "users/list" && req.method === "GET") {
|
||||
const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined;
|
||||
res.json(await listAdminUsers(req.query.limit, cursor));
|
||||
|
||||
@ -14,6 +14,56 @@ import {
|
||||
|
||||
const MAX_SEARCH_RESULTS = 30;
|
||||
|
||||
async function attachAdminStatus(users: AdminUserDto[]): Promise<AdminUserDto[]> {
|
||||
if (users.length === 0) return users;
|
||||
const records = await auth.getUsers(users.map(({ uid }) => ({ uid })));
|
||||
const adminUids = new Set(
|
||||
records.users
|
||||
.filter((record) => record.customClaims?.admin === true)
|
||||
.map((record) => record.uid)
|
||||
);
|
||||
return users.map((user) => ({ ...user, isAdmin: adminUids.has(user.uid) }));
|
||||
}
|
||||
|
||||
export interface AdminRoleUpdateResult {
|
||||
uid: string;
|
||||
isAdmin: true;
|
||||
alreadyAdmin: boolean;
|
||||
}
|
||||
|
||||
/** 기존 custom claims를 보존하면서 활성 유저에게 관리자 권한을 부여한다. */
|
||||
export async function appointAdmin(
|
||||
uidRaw: unknown,
|
||||
actorUid: string
|
||||
): Promise<AdminRoleUpdateResult> {
|
||||
if (
|
||||
typeof uidRaw !== "string" ||
|
||||
uidRaw.length < 1 ||
|
||||
uidRaw.length > 128 ||
|
||||
uidRaw.trim() !== uidRaw ||
|
||||
uidRaw.includes("/")
|
||||
) {
|
||||
throw new HttpError(400, "invalid uid", "INVALID_INPUT");
|
||||
}
|
||||
const user = await getUser(uidRaw);
|
||||
if (!user) throw new HttpError(404, "user not found", "USER_NOT_FOUND");
|
||||
if (user.active === false) {
|
||||
throw new HttpError(409, "inactive user cannot be admin", "INACTIVE_USER");
|
||||
}
|
||||
|
||||
const record = await auth.getUser(uidRaw).catch(() => {
|
||||
throw new HttpError(404, "auth user not found", "USER_NOT_FOUND");
|
||||
});
|
||||
if (record.customClaims?.admin === true) {
|
||||
return { uid: uidRaw, isAdmin: true, alreadyAdmin: true };
|
||||
}
|
||||
await auth.setCustomUserClaims(uidRaw, {
|
||||
...(record.customClaims ?? {}),
|
||||
admin: true,
|
||||
});
|
||||
return { uid: uidRaw, isAdmin: true, alreadyAdmin: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 어드민 유저 통합 검색. 한 입력으로 세 경로를 함께 조회한다:
|
||||
* - uid 정확 일치 (공백 없는 10자 이상 입력일 때만 문서 1건 조회)
|
||||
@ -51,7 +101,7 @@ export async function searchAdminUsers(
|
||||
add(uid, user);
|
||||
}
|
||||
|
||||
return [...results.values()].slice(0, limit);
|
||||
return attachAdminStatus([...results.values()].slice(0, limit));
|
||||
}
|
||||
|
||||
/** 가입일 내림차순 유저 목록 페이지. */
|
||||
@ -61,7 +111,7 @@ export async function listAdminUsers(
|
||||
): Promise<AdminUserPageDto> {
|
||||
const page = await listUsersByCreatedAt(Number(limitRaw) || 20, cursor);
|
||||
return {
|
||||
items: page.items.map(({ uid, user }) => toAdminUserDto(uid, user)),
|
||||
items: await attachAdminStatus(page.items.map(({ uid, user }) => toAdminUserDto(uid, user))),
|
||||
cursor: page.cursor,
|
||||
};
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ export interface AdminUserDto {
|
||||
favoriteTeamCode?: TeamCode;
|
||||
/** active 필드가 없는 구(백필 전) 문서는 활성으로 간주한다. */
|
||||
active: boolean;
|
||||
/** Firebase Auth custom claim의 admin=true 여부. */
|
||||
isAdmin: boolean;
|
||||
createdAt?: string;
|
||||
deactivatedAt?: string;
|
||||
}
|
||||
@ -25,13 +27,14 @@ export interface AdminUserPageDto {
|
||||
}
|
||||
|
||||
/** User 문서를 어드민 요약 DTO로 변환한다. 날짜는 UTC ISO 문자열, 옵션 필드는 키 자체를 생략. */
|
||||
export function toAdminUserDto(uid: string, user: User): AdminUserDto {
|
||||
export function toAdminUserDto(uid: string, user: User, isAdmin = false): AdminUserDto {
|
||||
return {
|
||||
uid,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
provider: user.provider,
|
||||
active: user.active !== false,
|
||||
isAdmin,
|
||||
...(user.photoUrl !== undefined ? { photoUrl: user.photoUrl } : {}),
|
||||
...(user.favoriteTeamCode !== undefined ? { favoriteTeamCode: user.favoriteTeamCode } : {}),
|
||||
...(user.createdAt ? { createdAt: toIsoOrUndefined(user.createdAt) } : {}),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { Timestamp } from "firebase-admin/firestore";
|
||||
import { firestore } from "../../src/firebase";
|
||||
import { listAdminUsers, searchAdminUsers } from "../../src/services/adminUserService";
|
||||
import { auth, firestore } from "../../src/firebase";
|
||||
import { appointAdmin, listAdminUsers, searchAdminUsers } from "../../src/services/adminUserService";
|
||||
|
||||
// 다른 테스트가 만드는 users 문서와 섞이지 않도록 고유 접두어를 쓴다.
|
||||
const PREFIX = "검색전용";
|
||||
@ -30,6 +30,7 @@ const seedUsers = [
|
||||
beforeAll(async () => {
|
||||
for (const { uid, ...doc } of seedUsers) {
|
||||
await firestore.doc(`users/${uid}`).set(doc);
|
||||
await auth.createUser({ uid, email: doc.email, displayName: doc.displayName });
|
||||
}
|
||||
});
|
||||
|
||||
@ -62,6 +63,7 @@ describe("searchAdminUsers", () => {
|
||||
const results = await searchAdminUsers("admin-user-search-test-1", 10);
|
||||
expect(results[0]?.uid).toBe("admin-user-search-test-1");
|
||||
expect(results[0]?.displayName).toBe(`${PREFIX}호랑이`);
|
||||
expect(results[0]?.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it("빈 q 는 400", async () => {
|
||||
@ -69,6 +71,36 @@ describe("searchAdminUsers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("appointAdmin", () => {
|
||||
it("기존 custom claims를 보존하면서 관리자 권한을 부여하고 재호출은 멱등이다", async () => {
|
||||
const uid = "admin-user-search-test-1";
|
||||
await auth.setCustomUserClaims(uid, { betaTester: true });
|
||||
|
||||
await expect(appointAdmin(uid, "granting-admin")).resolves.toEqual({
|
||||
uid,
|
||||
isAdmin: true,
|
||||
alreadyAdmin: false,
|
||||
});
|
||||
const record = await auth.getUser(uid);
|
||||
expect(record.customClaims).toMatchObject({
|
||||
betaTester: true,
|
||||
admin: true,
|
||||
});
|
||||
await expect(appointAdmin(uid, "granting-admin")).resolves.toMatchObject({
|
||||
isAdmin: true,
|
||||
alreadyAdmin: true,
|
||||
});
|
||||
const [dto] = await searchAdminUsers(uid, 10);
|
||||
expect(dto.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it("비활성 유저는 관리자로 임명할 수 없다", async () => {
|
||||
await expect(
|
||||
appointAdmin("admin-user-search-test-2", "granting-admin")
|
||||
).rejects.toMatchObject({ status: 409, code: "INACTIVE_USER" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAdminUsers", () => {
|
||||
it("가입일 내림차순 + 커서 페이지네이션", async () => {
|
||||
const first = await listAdminUsers(1);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user