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 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));
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) } : {}),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user