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:
윤정민 2026-07-22 17:47:43 +09:00
parent 9a66d3fb12
commit d30ab16e9f
4 changed files with 96 additions and 6 deletions

View File

@ -12,7 +12,7 @@ import { Timestamp } from "firebase-admin/firestore";
import type { OrderStatus, ProductDoc } from "../types/reward"; import type { OrderStatus, ProductDoc } from "../types/reward";
import { transitionOrder, updateOrderShipment } from "../services/orderService"; import { transitionOrder, updateOrderShipment } from "../services/orderService";
import { invalidateRewardCatalog } from "../services/rewardCatalogService"; import { invalidateRewardCatalog } from "../services/rewardCatalogService";
import { listAdminUsers, searchAdminUsers } from "../services/adminUserService"; import { appointAdmin, listAdminUsers, searchAdminUsers } from "../services/adminUserService";
import { import {
toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto, toAdminProductDto, toLedgerEntryDto, toOrderDto, toWalletDto,
type AdminProductDto, type LedgerPageDto, type OrderPageDto, type AdminProductDto, type LedgerPageDto, type OrderPageDto,
@ -132,6 +132,11 @@ export const admin = onRequest(async (req, res) => {
return; return;
} }
if (tail === "users/admin" && req.method === "POST") {
res.json(await appointAdmin(req.body?.uid, adminUid));
return;
}
if (tail === "users/list" && req.method === "GET") { if (tail === "users/list" && req.method === "GET") {
const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined; const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined;
res.json(await listAdminUsers(req.query.limit, cursor)); res.json(await listAdminUsers(req.query.limit, cursor));

View File

@ -14,6 +14,56 @@ import {
const MAX_SEARCH_RESULTS = 30; 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 ) * - uid ( 10 1 )
@ -51,7 +101,7 @@ export async function searchAdminUsers(
add(uid, user); 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> { ): Promise<AdminUserPageDto> {
const page = await listUsersByCreatedAt(Number(limitRaw) || 20, cursor); const page = await listUsersByCreatedAt(Number(limitRaw) || 20, cursor);
return { return {
items: page.items.map(({ uid, user }) => toAdminUserDto(uid, user)), items: await attachAdminStatus(page.items.map(({ uid, user }) => toAdminUserDto(uid, user))),
cursor: page.cursor, cursor: page.cursor,
}; };
} }

View File

@ -15,6 +15,8 @@ export interface AdminUserDto {
favoriteTeamCode?: TeamCode; favoriteTeamCode?: TeamCode;
/** active 필드가 없는 구(백필 전) 문서는 활성으로 간주한다. */ /** active 필드가 없는 구(백필 전) 문서는 활성으로 간주한다. */
active: boolean; active: boolean;
/** Firebase Auth custom claim의 admin=true 여부. */
isAdmin: boolean;
createdAt?: string; createdAt?: string;
deactivatedAt?: string; deactivatedAt?: string;
} }
@ -25,13 +27,14 @@ export interface AdminUserPageDto {
} }
/** User 문서를 어드민 요약 DTO로 변환한다. 날짜는 UTC ISO 문자열, 옵션 필드는 키 자체를 생략. */ /** User 문서를 어드민 요약 DTO로 변환한다. 날짜는 UTC ISO 문자열, 옵션 필드는 키 자체를 생략. */
export function toAdminUserDto(uid: string, user: User): AdminUserDto { export function toAdminUserDto(uid: string, user: User, isAdmin = false): AdminUserDto {
return { return {
uid, uid,
displayName: user.displayName, displayName: user.displayName,
email: user.email, email: user.email,
provider: user.provider, provider: user.provider,
active: user.active !== false, active: user.active !== false,
isAdmin,
...(user.photoUrl !== undefined ? { photoUrl: user.photoUrl } : {}), ...(user.photoUrl !== undefined ? { photoUrl: user.photoUrl } : {}),
...(user.favoriteTeamCode !== undefined ? { favoriteTeamCode: user.favoriteTeamCode } : {}), ...(user.favoriteTeamCode !== undefined ? { favoriteTeamCode: user.favoriteTeamCode } : {}),
...(user.createdAt ? { createdAt: toIsoOrUndefined(user.createdAt) } : {}), ...(user.createdAt ? { createdAt: toIsoOrUndefined(user.createdAt) } : {}),

View File

@ -1,7 +1,7 @@
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase"; import { auth, firestore } from "../../src/firebase";
import { listAdminUsers, searchAdminUsers } from "../../src/services/adminUserService"; import { appointAdmin, listAdminUsers, searchAdminUsers } from "../../src/services/adminUserService";
// 다른 테스트가 만드는 users 문서와 섞이지 않도록 고유 접두어를 쓴다. // 다른 테스트가 만드는 users 문서와 섞이지 않도록 고유 접두어를 쓴다.
const PREFIX = "검색전용"; const PREFIX = "검색전용";
@ -30,6 +30,7 @@ const seedUsers = [
beforeAll(async () => { beforeAll(async () => {
for (const { uid, ...doc } of seedUsers) { for (const { uid, ...doc } of seedUsers) {
await firestore.doc(`users/${uid}`).set(doc); 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); const results = await searchAdminUsers("admin-user-search-test-1", 10);
expect(results[0]?.uid).toBe("admin-user-search-test-1"); expect(results[0]?.uid).toBe("admin-user-search-test-1");
expect(results[0]?.displayName).toBe(`${PREFIX}호랑이`); expect(results[0]?.displayName).toBe(`${PREFIX}호랑이`);
expect(results[0]?.isAdmin).toBe(false);
}); });
it("빈 q 는 400", async () => { 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", () => { describe("listAdminUsers", () => {
it("가입일 내림차순 + 커서 페이지네이션", async () => { it("가입일 내림차순 + 커서 페이지네이션", async () => {
const first = await listAdminUsers(1); const first = await listAdminUsers(1);