diff --git a/src/handlers/adminHandlers.ts b/src/handlers/adminHandlers.ts index 71ed915..901e910 100644 --- a/src/handlers/adminHandlers.ts +++ b/src/handlers/adminHandlers.ts @@ -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)); diff --git a/src/services/adminUserService.ts b/src/services/adminUserService.ts index 7f48730..370898e 100644 --- a/src/services/adminUserService.ts +++ b/src/services/adminUserService.ts @@ -14,6 +14,56 @@ import { const MAX_SEARCH_RESULTS = 30; +async function attachAdminStatus(users: AdminUserDto[]): Promise { + 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 { + 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 { 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, }; } diff --git a/src/types/dto/adminUserDto.ts b/src/types/dto/adminUserDto.ts index 247b5bf..542ba38 100644 --- a/src/types/dto/adminUserDto.ts +++ b/src/types/dto/adminUserDto.ts @@ -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) } : {}), diff --git a/tests/services/adminUserService.test.ts b/tests/services/adminUserService.test.ts index ec5e033..7d4c123 100644 --- a/tests/services/adminUserService.test.ts +++ b/tests/services/adminUserService.test.ts @@ -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);