{t(
"msg.dev.clients.general.id_token_claims.hint",
- "RP 전용 확장 claim만 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.",
+ "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
)}
diff --git a/devfront/src/features/clients/ClientsPage.test.tsx b/devfront/src/features/clients/ClientsPage.test.tsx
index 2f92ecba..14918c4b 100644
--- a/devfront/src/features/clients/ClientsPage.test.tsx
+++ b/devfront/src/features/clients/ClientsPage.test.tsx
@@ -167,7 +167,67 @@ async function renderPage() {
return container;
}
+async function waitForTextContent(container: HTMLElement, text: string) {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ if (container.textContent?.includes(text)) {
+ return;
+ }
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ }
+
+ throw new Error(`Expected container text to include: ${text}`);
+}
+
describe("ClientsPage", () => {
+ it("does not show the legacy tenant scope label for unrestricted clients", async () => {
+ fetchClientsMock.mockResolvedValue({
+ items: [
+ {
+ ...makeClients(1)[0],
+ name: "Unrestricted App",
+ metadata: {
+ tenant_access_restricted: false,
+ allowed_tenants: [],
+ },
+ },
+ ],
+ limit: 100,
+ offset: 0,
+ });
+
+ const container = await renderPage();
+
+ expect(container.textContent).toContain("Unrestricted App");
+ expect(container.textContent).not.toContain("Tenant-scoped");
+ expect(container.textContent).not.toContain("Tenant-limited");
+ });
+
+ it("shows Tenant-limited only when client tenant access is restricted", async () => {
+ fetchClientsMock.mockResolvedValue({
+ items: [
+ {
+ ...makeClients(1)[0],
+ name: "Limited App",
+ metadata: {
+ tenant_access_restricted: true,
+ allowed_tenants: ["tenant-1"],
+ },
+ },
+ ],
+ limit: 100,
+ offset: 0,
+ });
+
+ const container = await renderPage();
+
+ expect(container.textContent).toContain("Limited App");
+ expect(container.textContent).toContain("Tenant-limited");
+ expect(container.textContent).not.toContain("Tenant-scoped");
+ });
+
it("expands the list and applies search filters", async () => {
fetchClientsMock.mockResolvedValue({
items: makeClients(6),
@@ -277,4 +337,76 @@ describe("ClientsPage", () => {
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
+
+ it("allows a user without tenant context to request developer access", async () => {
+ authState = {
+ user: {
+ access_token: "access-token",
+ profile: {
+ role: "user",
+ companyCode: "HANMAC",
+ name: "Requester",
+ email: "requester@example.com",
+ phone: "010-1234-5678",
+ },
+ },
+ };
+ fetchMeMock.mockResolvedValue({
+ role: "user",
+ name: "Requester",
+ email: "requester@example.com",
+ phone: "010-1234-5678",
+ });
+ fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" });
+
+ const container = await renderPage();
+ await waitForTextContent(container, "개발자 등록 신청하기");
+
+ const requestButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent === "개발자 등록 신청하기",
+ );
+ expect(requestButton).toBeTruthy();
+
+ await act(async () => {
+ requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
+ expect(fetchDeveloperRequestStatusMock).toHaveBeenCalled();
+ });
+
+ it("shows the create app button for a super admin without tenant context", async () => {
+ authState = {
+ user: {
+ access_token: "access-token",
+ profile: {
+ role: "super_admin",
+ companyCode: "HANMAC",
+ name: "Dev Admin",
+ email: "dev@example.com",
+ phone: "010-0000-0000",
+ },
+ },
+ };
+ fetchMeMock.mockResolvedValue({
+ role: "super_admin",
+ name: "Dev Admin",
+ email: "dev@example.com",
+ phone: "010-0000-0000",
+ });
+
+ const container = await renderPage();
+ expect(container.textContent).toContain("연동 앱 추가");
+
+ const createButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent === "연동 앱 추가",
+ );
+ expect(createButton).toBeTruthy();
+
+ await act(async () => {
+ createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(navigateMock).toHaveBeenCalledWith("/clients/new");
+ });
});
diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx
index c82680f6..6552c03e 100644
--- a/devfront/src/features/clients/ClientsPage.tsx
+++ b/devfront/src/features/clients/ClientsPage.tsx
@@ -59,6 +59,19 @@ import { ClientLogo } from "./components/ClientLogo";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
const clientListPreviewCount = 5;
+function isClientTenantLimited(client: ClientSummary) {
+ const metadata = client.metadata ?? {};
+ if (metadata.tenant_access_restricted === true) {
+ return true;
+ }
+ if (!Array.isArray(metadata.allowed_tenants)) {
+ return false;
+ }
+ return metadata.allowed_tenants.some(
+ (tenantId) => typeof tenantId === "string" && tenantId.trim() !== "",
+ );
+}
+
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
@@ -93,9 +106,7 @@ function ClientsPage() {
} = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
- enabled:
- hasAccessToken &&
- (profileRole === "user" || profileRole === "tenant_member"),
+ enabled: hasAccessToken && profileRole === "user",
});
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
@@ -105,11 +116,11 @@ function ClientsPage() {
const createAccessState = resolveClientCreateAccess({
role: profileRole,
- requestStatus: requestStatus?.status,
+ accessStatus: requestStatus,
});
const canCreateClient = createAccessState === "can_create";
- const isDeveloperRequestPending = createAccessState === "pending";
- const canRequestDeveloperAccess =
+ const isClientCreatePending = createAccessState === "pending";
+ const canRequestClientCreateAccess =
createAccessState === "request_required" && !isLoadingRequest;
const [searchQuery, setSearchQuery] = useState("");
@@ -240,7 +251,7 @@ function ClientsPage() {
{t("ui.dev.clients.new", "새 클라이언트")}
- ) : isDeveloperRequestPending ? (
+ ) : isClientCreatePending ? (
{t(
@@ -257,7 +268,7 @@ function ClientsPage() {
{t("ui.dev.nav.developer_request", "개발자 권한 신청")}
- ) : canRequestDeveloperAccess ? (
+ ) : canRequestClientCreateAccess ? (
{t(
@@ -460,7 +471,7 @@ function ClientsPage() {
"msg.dev.clients.empty_can_create",
"아직 등록된 연동 앱이 없습니다.",
)
- : isDeveloperRequestPending
+ : isClientCreatePending
? t(
"msg.dev.clients.empty_pending",
"개발자 권한 신청을 검토 중입니다.",
@@ -482,7 +493,7 @@ function ClientsPage() {
"msg.dev.clients.empty_can_create_detail",
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
)
- : isDeveloperRequestPending
+ : isClientCreatePending
? t(
"msg.dev.clients.empty_pending_detail",
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
@@ -501,7 +512,7 @@ function ClientsPage() {
{t("ui.dev.clients.new", "연동 앱 추가")}
)}
- {!isFilteredOut && canRequestDeveloperAccess && (
+ {!isFilteredOut && canRequestClientCreateAccess && (
@@ -695,6 +708,7 @@ function RequestAccessModal({
organization,
reason,
tenantId,
+ accessPages: ["all"],
});
};
diff --git a/devfront/src/features/clients/clientCreateAccess.test.ts b/devfront/src/features/clients/clientCreateAccess.test.ts
index 79200c10..29464eb9 100644
--- a/devfront/src/features/clients/clientCreateAccess.test.ts
+++ b/devfront/src/features/clients/clientCreateAccess.test.ts
@@ -5,7 +5,7 @@ describe("client create access", () => {
it("allows privileged roles to create clients without developer request approval", () => {
expect(
resolveClientCreateAccess({
- role: "rp_admin",
+ role: "super_admin",
}),
).toBe("can_create");
});
@@ -14,7 +14,7 @@ describe("client create access", () => {
expect(
resolveClientCreateAccess({
role: "user",
- requestStatus: "none",
+ accessStatus: { status: "none" },
}),
).toBe("request_required");
});
@@ -23,7 +23,7 @@ describe("client create access", () => {
expect(
resolveClientCreateAccess({
role: "",
- requestStatus: undefined,
+ accessStatus: undefined,
}),
).toBe("request_required");
});
@@ -31,8 +31,8 @@ describe("client create access", () => {
it("shows pending state while a developer request is under review", () => {
expect(
resolveClientCreateAccess({
- role: "tenant_member",
- requestStatus: "pending",
+ role: "user",
+ accessStatus: { status: "pending", pendingPages: ["client_create"] },
}),
).toBe("pending");
});
@@ -41,7 +41,10 @@ describe("client create access", () => {
expect(
resolveClientCreateAccess({
role: "user",
- requestStatus: "approved",
+ accessStatus: {
+ status: "approved",
+ approvedPages: ["client_create"],
+ },
}),
).toBe("can_create");
});
@@ -50,14 +53,14 @@ describe("client create access", () => {
expect(
resolveClientCreateAccess({
role: "user",
- requestStatus: "cancelled",
+ accessStatus: { status: "cancelled" },
}),
).toBe("request_required");
expect(
resolveClientCreateAccess({
role: "user",
- requestStatus: "rejected",
+ accessStatus: { status: "rejected" },
}),
).toBe("request_required");
});
diff --git a/devfront/src/features/clients/clientCreateAccess.ts b/devfront/src/features/clients/clientCreateAccess.ts
index 64e3e556..ddb2af34 100644
--- a/devfront/src/features/clients/clientCreateAccess.ts
+++ b/devfront/src/features/clients/clientCreateAccess.ts
@@ -1,4 +1,8 @@
-import type { DeveloperRequestStatus } from "../../lib/devApi";
+import type { DeveloperAccessStatus } from "../../lib/devApi";
+import {
+ hasDeveloperAccessForPages,
+ isDeveloperRequestPendingForPages,
+} from "../developer-access/developerAccessPages";
export type ClientCreateAccessState =
| "can_create"
@@ -8,16 +12,16 @@ export type ClientCreateAccessState =
type ResolveClientCreateAccessParams = {
role: string;
- requestStatus?: DeveloperRequestStatus;
+ accessStatus?: DeveloperAccessStatus;
};
function canSelfRequestDeveloperAccess(role: string) {
- return role === "user" || role === "tenant_member";
+ return role === "user";
}
export function resolveClientCreateAccess({
role,
- requestStatus,
+ accessStatus,
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
if (!role.trim()) {
return "request_required";
@@ -27,22 +31,19 @@ export function resolveClientCreateAccess({
return "can_create";
}
- if (requestStatus === "approved") {
+ if (
+ hasDeveloperAccessForPages(accessStatus?.approvedPages, ["client_create"])
+ ) {
return "can_create";
}
- if (requestStatus === "pending") {
+ if (
+ isDeveloperRequestPendingForPages(accessStatus?.pendingPages, [
+ "client_create",
+ ])
+ ) {
return "pending";
}
- if (
- requestStatus === "none" ||
- requestStatus === "rejected" ||
- requestStatus === "cancelled" ||
- typeof requestStatus === "undefined"
- ) {
- return "request_required";
- }
-
- return "forbidden";
+ return "request_required";
}
diff --git a/devfront/src/features/clients/rpClaimDateTime.test.ts b/devfront/src/features/clients/rpClaimDateTime.test.ts
new file mode 100644
index 00000000..26d52e02
--- /dev/null
+++ b/devfront/src/features/clients/rpClaimDateTime.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import {
+ claimDateTimeValueToInputString,
+ dateTimeInputToUnixSeconds,
+ unixSecondsToDateTimeInput,
+} from "./rpClaimDateTime";
+
+describe("rpClaimDateTime", () => {
+ it("converts date and datetime input in a selected timezone to Unix seconds", () => {
+ expect(dateTimeInputToUnixSeconds("2026-06-10", "date", "Asia/Seoul")).toBe(
+ 1781017200,
+ );
+ expect(
+ dateTimeInputToUnixSeconds("2026-06-09T10:30", "datetime", "Asia/Seoul"),
+ ).toBe(1780968600);
+ });
+
+ it("formats stored Unix seconds for the selected timezone", () => {
+ expect(unixSecondsToDateTimeInput(1781017200, "date", "Asia/Seoul")).toBe(
+ "2026-06-10",
+ );
+ expect(
+ unixSecondsToDateTimeInput(1780968600, "datetime", "Asia/Seoul"),
+ ).toBe("2026-06-09T10:30");
+ });
+
+ it("uses Unix seconds values when hydrating date inputs", () => {
+ expect(
+ claimDateTimeValueToInputString(1780968600, "", "datetime", "Asia/Seoul"),
+ ).toBe("2026-06-09T10:30");
+ });
+});
diff --git a/devfront/src/features/clients/rpClaimDateTime.ts b/devfront/src/features/clients/rpClaimDateTime.ts
new file mode 100644
index 00000000..a60a5fea
--- /dev/null
+++ b/devfront/src/features/clients/rpClaimDateTime.ts
@@ -0,0 +1,137 @@
+export type RPClaimDateTimeValueType = "date" | "datetime";
+
+export const FALLBACK_TIME_ZONE = "UTC";
+
+export function getBrowserTimeZone(): string {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || FALLBACK_TIME_ZONE;
+}
+
+export function getSupportedTimeZones(currentTimeZone = getBrowserTimeZone()) {
+ const supported =
+ typeof Intl.supportedValuesOf === "function"
+ ? Intl.supportedValuesOf("timeZone")
+ : [];
+ return Array.from(
+ new Set([currentTimeZone, FALLBACK_TIME_ZONE, ...supported]),
+ );
+}
+
+function getTimeZoneOffsetMs(date: Date, timeZone: string) {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ hour12: false,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ }).formatToParts(date);
+ const values = Object.fromEntries(
+ parts
+ .filter((part) => part.type !== "literal")
+ .map((part) => [part.type, part.value]),
+ );
+ const hour = values.hour === "24" ? "00" : values.hour;
+ const asUTC = Date.UTC(
+ Number(values.year),
+ Number(values.month) - 1,
+ Number(values.day),
+ Number(hour),
+ Number(values.minute),
+ Number(values.second),
+ );
+ return asUTC - date.getTime();
+}
+
+function zonedDateTimeToUnixSeconds(
+ year: number,
+ month: number,
+ day: number,
+ hour: number,
+ minute: number,
+ timeZone: string,
+) {
+ const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0);
+ let instant = utcGuess - getTimeZoneOffsetMs(new Date(utcGuess), timeZone);
+ const corrected = utcGuess - getTimeZoneOffsetMs(new Date(instant), timeZone);
+ if (corrected !== instant) {
+ instant = corrected;
+ }
+ return Math.trunc(instant / 1000);
+}
+
+export function dateTimeInputToUnixSeconds(
+ value: string,
+ valueType: RPClaimDateTimeValueType,
+ timeZone: string,
+): number | null {
+ const trimmed = value.trim();
+ const match =
+ valueType === "date"
+ ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed)
+ : /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(trimmed);
+ if (!match) return null;
+
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ const day = Number(match[3]);
+ const hour = valueType === "datetime" ? Number(match[4]) : 0;
+ const minute = valueType === "datetime" ? Number(match[5]) : 0;
+ const unixSeconds = zonedDateTimeToUnixSeconds(
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ timeZone || FALLBACK_TIME_ZONE,
+ );
+ return Number.isFinite(unixSeconds) ? unixSeconds : null;
+}
+
+export function unixSecondsToDateTimeInput(
+ value: number,
+ valueType: RPClaimDateTimeValueType,
+ timeZone: string,
+) {
+ const date = new Date(value * 1000);
+ if (Number.isNaN(date.getTime())) return "";
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone: timeZone || FALLBACK_TIME_ZONE,
+ hour12: false,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).formatToParts(date);
+ const values = Object.fromEntries(
+ parts
+ .filter((part) => part.type !== "literal")
+ .map((part) => [part.type, part.value]),
+ );
+ const hour = values.hour === "24" ? "00" : values.hour;
+ const dateText = `${values.year}-${values.month}-${values.day}`;
+ if (valueType === "date") return dateText;
+ return `${dateText}T${hour}:${values.minute}`;
+}
+
+export function claimDateTimeValueToInputString(
+ value: unknown,
+ fallback: string,
+ valueType: RPClaimDateTimeValueType,
+ timeZone: string,
+) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return unixSecondsToDateTimeInput(value, valueType, timeZone);
+ }
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
+ return unixSecondsToDateTimeInput(
+ Number(value.trim()),
+ valueType,
+ timeZone,
+ );
+ }
+ const text = typeof value === "string" ? value : fallback;
+ return valueType === "date" ? text.slice(0, 10) : text.slice(0, 16);
+}
diff --git a/devfront/src/features/coverage/pageSmoke.test.tsx b/devfront/src/features/coverage/pageSmoke.test.tsx
index f94072c1..27e82dcb 100644
--- a/devfront/src/features/coverage/pageSmoke.test.tsx
+++ b/devfront/src/features/coverage/pageSmoke.test.tsx
@@ -11,12 +11,16 @@ import ClientRelationsPage from "../clients/ClientRelationsPage";
import ClientsPage from "../clients/ClientsPage";
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
+import DeveloperGrantsPage from "../developer-grants/DeveloperGrantsPage";
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
import ProfilePage from "../profile/ProfilePage";
import {
approveDeveloperRequest,
cancelDeveloperRequestApproval,
+ createDeveloperGrant,
+ fetchDeveloperGrants,
rejectDeveloperRequest,
+ revokeDeveloperGrant,
} from "../../lib/devApi";
const authProfile = {
@@ -195,6 +199,29 @@ vi.mock("../../lib/devApi", () => ({
},
],
})),
+ fetchDevUser: vi.fn(async () => ({
+ id: "user-2",
+ email: "editor@example.com",
+ name: "Editor User",
+ phone: "010-1111-2222",
+ role: "user",
+ status: "active",
+ tenant: {
+ id: "tenant-1",
+ name: "Hanmac",
+ slug: "hanmac",
+ type: "COMPANY",
+ status: "active",
+ description: "",
+ memberCount: 10,
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ },
+ tenantSlug: "hanmac",
+ companyCode: "HANMAC",
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ })),
addClientRelation: vi.fn(async () => ({
relation: "admins",
subject: "User:user-2",
@@ -290,6 +317,24 @@ vi.mock("../../lib/devApi", () => ({
updatedAt: "2026-05-01T00:00:00Z",
},
]),
+ fetchTenants: vi.fn(async () => ({
+ items: [
+ {
+ id: "tenant-1",
+ name: "Hanmac",
+ slug: "hanmac",
+ type: "COMPANY",
+ status: "active",
+ description: "",
+ memberCount: 10,
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ },
+ ],
+ limit: 1000,
+ offset: 0,
+ total: 1,
+ })),
fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })),
requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })),
fetchDeveloperRequests: vi.fn(async () => [
@@ -319,9 +364,26 @@ vi.mock("../../lib/devApi", () => ({
updatedAt: "2026-05-02T00:00:00Z",
},
]),
+ fetchDeveloperGrants: vi.fn(async () => [
+ {
+ id: 3,
+ userId: "user-5",
+ tenantId: "tenant-1",
+ name: "Granted User",
+ organization: "Hanmac",
+ email: "granted@example.com",
+ reason: "Direct grant",
+ status: "approved",
+ adminNotes: "Manual grant",
+ createdAt: "2026-05-03T00:00:00Z",
+ updatedAt: "2026-05-03T00:00:00Z",
+ },
+ ]),
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })),
+ createDeveloperGrant: vi.fn(async () => ({ status: "approved" })),
+ revokeDeveloperGrant: vi.fn(async () => ({ status: "ok" })),
}));
vi.mock("../auth/authApi", () => ({
@@ -408,6 +470,9 @@ describe("devfront coverage smoke pages", () => {
const requests = await renderPage();
expect(requests.textContent).toContain("Requester");
+ const grants = await renderPage();
+ expect(grants.textContent).toContain("개발자 권한 부여");
+
const profile = await renderPage();
expect(profile.textContent).toContain("Dev Admin");
});
@@ -427,7 +492,8 @@ describe("devfront coverage smoke pages", () => {
expect(settings.textContent).not.toContain("top-level");
expect(settings.textContent).toContain("Date");
expect(settings.textContent).toContain("Datetime");
- expect(settings.textContent).toContain("관리자만 가능");
+ expect(settings.textContent).toContain("User read");
+ expect(settings.textContent).toContain("User write");
const consents = await renderPage(, {
path: "/clients/:id/consents",
diff --git a/devfront/src/features/developer-access/developerAccessGate.test.ts b/devfront/src/features/developer-access/developerAccessGate.test.ts
index 02acae89..3eea95ce 100644
--- a/devfront/src/features/developer-access/developerAccessGate.test.ts
+++ b/devfront/src/features/developer-access/developerAccessGate.test.ts
@@ -8,30 +8,63 @@ import {
describe("developer access gate", () => {
it("fetches request status only for user roles", () => {
expect(shouldFetchDeveloperRequestStatus("user")).toBe(true);
- expect(shouldFetchDeveloperRequestStatus("tenant_admin")).toBe(false);
- expect(shouldFetchDeveloperRequestStatus("rp_admin")).toBe(false);
+ expect(shouldFetchDeveloperRequestStatus("super_admin")).toBe(false);
});
it("resolves access and request states from the request status", () => {
- expect(resolveDeveloperAccessGate("super_admin", "pending")).toEqual({
+ expect(
+ resolveDeveloperAccessGate("super_admin", {
+ status: "pending",
+ pendingPages: ["overview"],
+ }),
+ ).toEqual({
hasDeveloperAccess: true,
isDeveloperRequestPending: true,
canRequestDeveloperAccess: false,
});
- expect(resolveDeveloperAccessGate("user", "approved")).toEqual({
+ expect(
+ resolveDeveloperAccessGate("user", {
+ status: "approved",
+ approvedPages: ["overview"],
+ }),
+ ).toEqual({
hasDeveloperAccess: true,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
});
- expect(resolveDeveloperAccessGate("user", "pending")).toEqual({
+ expect(
+ resolveDeveloperAccessGate(
+ "user",
+ {
+ status: "pending",
+ pendingPages: ["audit"],
+ },
+ ["audit"],
+ ),
+ ).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: true,
canRequestDeveloperAccess: false,
});
- expect(resolveDeveloperAccessGate("user", "none")).toEqual({
+ expect(
+ resolveDeveloperAccessGate(
+ "user",
+ {
+ status: "approved",
+ approvedPages: ["overview"],
+ },
+ ["audit"],
+ ),
+ ).toEqual({
+ hasDeveloperAccess: false,
+ isDeveloperRequestPending: false,
+ canRequestDeveloperAccess: true,
+ });
+
+ expect(resolveDeveloperAccessGate("user", { status: "none" })).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: true,
@@ -41,7 +74,7 @@ describe("developer access gate", () => {
it("shows the loading gate only for user requests", () => {
expect(shouldShowDeveloperAccessLoading("user", true, false)).toBe(true);
expect(shouldShowDeveloperAccessLoading("user", false, true)).toBe(true);
- expect(shouldShowDeveloperAccessLoading("tenant_admin", true, true)).toBe(
+ expect(shouldShowDeveloperAccessLoading("super_admin", true, true)).toBe(
false,
);
});
diff --git a/devfront/src/features/developer-access/developerAccessGate.ts b/devfront/src/features/developer-access/developerAccessGate.ts
index b482da4b..18e8379e 100644
--- a/devfront/src/features/developer-access/developerAccessGate.ts
+++ b/devfront/src/features/developer-access/developerAccessGate.ts
@@ -1,31 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import {
- type DeveloperRequestStatus,
+ type DeveloperAccessStatus,
fetchDeveloperRequestStatus,
} from "../../lib/devApi";
+import {
+ type DeveloperAccessPage,
+ hasDeveloperAccessForPages,
+ isDeveloperRequestPendingForPages,
+} from "./developerAccessPages";
export type DeveloperAccessGateState = {
hasDeveloperAccess: boolean;
isDeveloperRequestPending: boolean;
canRequestDeveloperAccess: boolean;
isLoadingDeveloperAccessGate: boolean;
+ isTenantContextMissing: boolean;
};
-function isPrivilegedDeveloperRole(profileRole: string) {
- return (
- profileRole === "super_admin" ||
- profileRole === "rp_admin" ||
- profileRole === "tenant_admin"
- );
-}
-
export function resolveDeveloperAccessGate(
profileRole: string,
- requestStatus?: DeveloperRequestStatus,
-): Omit {
+ accessStatus?: DeveloperAccessStatus,
+ requiredPages: DeveloperAccessPage[] = ["overview"],
+): Omit<
+ DeveloperAccessGateState,
+ "isLoadingDeveloperAccessGate" | "isTenantContextMissing"
+> {
const hasDeveloperAccess =
- isPrivilegedDeveloperRole(profileRole) || requestStatus === "approved";
- const isDeveloperRequestPending = requestStatus === "pending";
+ profileRole === "super_admin" ||
+ hasDeveloperAccessForPages(accessStatus?.approvedPages, requiredPages);
+ const isDeveloperRequestPending = isDeveloperRequestPendingForPages(
+ accessStatus?.pendingPages,
+ requiredPages,
+ );
const canRequestDeveloperAccess =
profileRole === "user" && !hasDeveloperAccess && !isDeveloperRequestPending;
@@ -54,15 +60,18 @@ export function useDeveloperAccessGate({
hasAccessToken,
profileRole,
tenantId,
+ requiredPages = ["overview"],
isLoadingIdentity = false,
}: {
hasAccessToken: boolean;
profileRole: string;
tenantId?: string;
+ requiredPages?: DeveloperAccessPage[];
isLoadingIdentity?: boolean;
}) {
const shouldFetchRequestStatus =
shouldFetchDeveloperRequestStatus(profileRole);
+ const isTenantContextMissing = !tenantId?.trim();
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
@@ -71,11 +80,14 @@ export function useDeveloperAccessGate({
const resolvedGate = resolveDeveloperAccessGate(
profileRole,
- requestStatus?.status,
+ requestStatus,
+ requiredPages,
);
return {
...resolvedGate,
+ isTenantContextMissing,
+ canRequestDeveloperAccess: resolvedGate.canRequestDeveloperAccess,
isLoadingDeveloperAccessGate: shouldShowDeveloperAccessLoading(
profileRole,
isLoadingIdentity,
diff --git a/devfront/src/features/developer-access/developerAccessPages.test.ts b/devfront/src/features/developer-access/developerAccessPages.test.ts
new file mode 100644
index 00000000..2415cf42
--- /dev/null
+++ b/devfront/src/features/developer-access/developerAccessPages.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { normalizeDeveloperAccessPageSelection } from "./developerAccessPages";
+
+describe("developer access pages", () => {
+ it("collapses all non-all pages into all", () => {
+ expect(
+ normalizeDeveloperAccessPageSelection([
+ "overview",
+ "client_create",
+ "audit",
+ ]),
+ ).toEqual(["all"]);
+ });
+
+ it("keeps partial selections as-is", () => {
+ expect(
+ normalizeDeveloperAccessPageSelection(["overview", "audit"]),
+ ).toEqual(["overview", "audit"]);
+ });
+
+ it("keeps explicit all selection", () => {
+ expect(normalizeDeveloperAccessPageSelection(["all"])).toEqual(["all"]);
+ });
+});
diff --git a/devfront/src/features/developer-access/developerAccessPages.ts b/devfront/src/features/developer-access/developerAccessPages.ts
new file mode 100644
index 00000000..ce7608f1
--- /dev/null
+++ b/devfront/src/features/developer-access/developerAccessPages.ts
@@ -0,0 +1,102 @@
+export type DeveloperAccessPage =
+ | "all"
+ | "overview"
+ | "client_create"
+ | "audit";
+
+export const developerAccessPageOrder: DeveloperAccessPage[] = [
+ "overview",
+ "client_create",
+ "audit",
+];
+
+export const developerAccessPageOptions: Array<{
+ value: DeveloperAccessPage;
+ label: string;
+}> = [
+ { value: "all", label: "전체" },
+ { value: "overview", label: "개요" },
+ { value: "client_create", label: "연동 앱 추가" },
+ { value: "audit", label: "감사로그" },
+];
+
+export function normalizeDeveloperAccessPages(
+ pages: Array,
+): DeveloperAccessPage[] {
+ const normalized = new Set();
+ for (const raw of pages) {
+ const page = String(raw ?? "")
+ .trim()
+ .toLowerCase();
+ if (!page) {
+ continue;
+ }
+ if (page === "all") {
+ return ["all"];
+ }
+ if (page === "overview" || page === "client_create" || page === "audit") {
+ normalized.add(page);
+ }
+ }
+
+ return [...developerAccessPageOrder.filter((page) => normalized.has(page))];
+}
+
+export function normalizeDeveloperAccessPageSelection(
+ pages: DeveloperAccessPage[],
+): DeveloperAccessPage[] {
+ if (pages.includes("all")) {
+ return ["all"];
+ }
+ const normalized = normalizeDeveloperAccessPages(pages);
+ if (normalized.length === 0) {
+ return ["all"];
+ }
+ if (normalized.length === developerAccessPageOrder.length) {
+ return ["all"];
+ }
+ return normalized;
+}
+
+export function developerAccessPagesToLabel(pages?: Array) {
+ const normalized = normalizeDeveloperAccessPages(pages ?? []);
+ if (normalized.length === 0 || normalized.includes("all")) {
+ return "전체";
+ }
+ return normalized
+ .map((page) => {
+ switch (page) {
+ case "overview":
+ return "개요";
+ case "client_create":
+ return "연동 앱 추가";
+ case "audit":
+ return "감사로그";
+ default:
+ return page;
+ }
+ })
+ .join(", ");
+}
+
+export function hasDeveloperAccessForPages(
+ grantedPages: Array | undefined,
+ requiredPages: DeveloperAccessPage[],
+) {
+ const normalized = normalizeDeveloperAccessPages(grantedPages ?? []);
+ if (normalized.includes("all")) {
+ return true;
+ }
+ return requiredPages.some((page) => normalized.includes(page));
+}
+
+export function isDeveloperRequestPendingForPages(
+ pendingPages: Array | undefined,
+ requiredPages: DeveloperAccessPage[],
+) {
+ const normalized = normalizeDeveloperAccessPages(pendingPages ?? []);
+ if (normalized.includes("all")) {
+ return true;
+ }
+ return requiredPages.some((page) => normalized.includes(page));
+}
diff --git a/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx
new file mode 100644
index 00000000..8b967b86
--- /dev/null
+++ b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx
@@ -0,0 +1,681 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
+import { KeyRound, Plus, Search, ShieldCheck, X } from "lucide-react";
+import { useDeferredValue, useMemo, useState } from "react";
+import { useAuth } from "react-oidc-context";
+import { PageHeader } from "../../../../common/core/components/page";
+import { Badge } from "../../components/ui/badge";
+import { Button } from "../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../components/ui/card";
+import { Input } from "../../components/ui/input";
+import { Label } from "../../components/ui/label";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "../../components/ui/table";
+import { Textarea } from "../../components/ui/textarea";
+import { toast } from "../../components/ui/use-toast";
+import {
+ createDeveloperGrant,
+ type DevAssignableUser,
+ fetchDeveloperGrants,
+ fetchDevUser,
+ fetchDevUsers,
+ revokeDeveloperGrant,
+} from "../../lib/devApi";
+import { t } from "../../lib/i18n";
+import { resolveProfileRole } from "../../lib/role";
+import { fetchMe } from "../auth/authApi";
+import {
+ type DeveloperAccessPage,
+ developerAccessPageOptions,
+ normalizeDeveloperAccessPageSelection,
+ normalizeDeveloperAccessPages,
+} from "../developer-access/developerAccessPages";
+
+function formatUserLabel(user: DevAssignableUser) {
+ const primary = user.name.trim() || user.email.trim();
+ return `${primary} (${user.email.trim()})`;
+}
+
+export default function DeveloperGrantsPage() {
+ const auth = useAuth();
+ const queryClient = useQueryClient();
+ const hasAccessToken = Boolean(auth.user?.access_token);
+ const userProfile = auth.user?.profile as Record | undefined;
+ const role = resolveProfileRole(userProfile);
+
+ const { data: me, isLoading: isLoadingMe } = useQuery({
+ queryKey: ["userMe"],
+ queryFn: fetchMe,
+ enabled: hasAccessToken,
+ });
+ const profileRole = me?.role?.trim() || role;
+ const isSuperAdmin = profileRole === "super_admin";
+
+ const [userSearch, setUserSearch] = useState("");
+ const deferredUserSearch = useDeferredValue(userSearch.trim());
+ const [selectedUser, setSelectedUser] = useState(
+ null,
+ );
+ const [selectedAccessPages, setSelectedAccessPages] = useState<
+ DeveloperAccessPage[]
+ >(["all"]);
+ const [grantNotes, setGrantNotes] = useState("");
+ const [adminNotes, setAdminNotes] = useState>({});
+
+ const { data: userSearchData, isFetching: isUserSearchLoading } = useQuery({
+ queryKey: ["developer-grant-users", deferredUserSearch],
+ queryFn: () => fetchDevUsers(deferredUserSearch, 10),
+ enabled:
+ hasAccessToken &&
+ isSuperAdmin &&
+ deferredUserSearch.length > 0 &&
+ selectedUser == null,
+ });
+
+ const { data: selectedUserDetail, isFetching: isSelectedUserDetailLoading } =
+ useQuery({
+ queryKey: ["developer-grant-user", selectedUser?.id],
+ queryFn: () => fetchDevUser(selectedUser?.id || ""),
+ enabled: hasAccessToken && isSuperAdmin && selectedUser != null,
+ });
+
+ const {
+ data: grants,
+ isLoading: isLoadingGrants,
+ error: grantsError,
+ } = useQuery({
+ queryKey: ["developer-grants"],
+ queryFn: () => fetchDeveloperGrants(),
+ enabled: hasAccessToken && isSuperAdmin,
+ });
+
+ const grantList = grants ?? [];
+
+ const filteredGrantedUsers = useMemo(() => {
+ return [...grantList].sort((a, b) => {
+ const tenantCompare = a.organization.localeCompare(b.organization);
+ if (tenantCompare !== 0) {
+ return tenantCompare;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ }, [grantList]);
+
+ const createGrantMutation = useMutation({
+ mutationFn: createDeveloperGrant,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["developer-grants"] });
+ toast(
+ t(
+ "msg.dev.grants.create_success",
+ "개발자 권한이 직접 부여되었습니다.",
+ ),
+ "success",
+ );
+ setSelectedUser(null);
+ setUserSearch("");
+ setSelectedAccessPages(["all"]);
+ setGrantNotes("");
+ },
+ onError: (err: AxiosError<{ error?: string }> | Error) => {
+ toast(
+ (err as AxiosError<{ error?: string }>).response?.data?.error ||
+ (err as Error).message ||
+ t("msg.common.error", "오류가 발생했습니다."),
+ "error",
+ );
+ },
+ });
+
+ const revokeGrantMutation = useMutation({
+ mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
+ revokeDeveloperGrant(id, adminNotes),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["developer-grants"] });
+ toast(
+ t("msg.dev.grants.revoke_success", "개발자 권한이 회수되었습니다."),
+ "success",
+ );
+ },
+ onError: (err: AxiosError<{ error?: string }> | Error) => {
+ toast(
+ (err as AxiosError<{ error?: string }>).response?.data?.error ||
+ (err as Error).message ||
+ t("msg.common.error", "오류가 발생했습니다."),
+ "error",
+ );
+ },
+ });
+
+ if (isLoadingMe) {
+ return (
+
+ {t("ui.common.loading", "Loading...")}
+
+ );
+ }
+
+ if (!isSuperAdmin) {
+ return (
+
+ }
+ title={t("ui.dev.nav.developer_grants", "개발자 권한 부여")}
+ description={t(
+ "msg.dev.grants.forbidden_desc",
+ "이 화면은 super admin만 사용할 수 있습니다.",
+ )}
+ />
+
+
+ {t(
+ "msg.dev.grants.forbidden",
+ "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다.",
+ )}
+
+
+
+ );
+ }
+
+ const handleGrant = () => {
+ if (!selectedUser) {
+ toast(
+ t("msg.dev.grants.user_required", "부여할 사용자를 선택해주세요."),
+ "error",
+ );
+ return;
+ }
+ const tenantId =
+ selectedUserDetail?.tenant?.id?.trim() ||
+ selectedUserDetail?.tenantSlug?.trim() ||
+ selectedUserDetail?.companyCode?.trim() ||
+ "";
+
+ createGrantMutation.mutate({
+ userId: selectedUser.id,
+ tenantId,
+ reason: grantNotes.trim() || "직접 부여",
+ adminNotes: grantNotes.trim(),
+ accessPages: normalizeDeveloperAccessPageSelection(selectedAccessPages),
+ });
+ };
+
+ const handleSelectUser = (user: DevAssignableUser) => {
+ setSelectedUser(user);
+ setUserSearch(formatUserLabel(user));
+ setSelectedAccessPages(["all"]);
+ };
+
+ const handleAccessPageToggle = (page: DeveloperAccessPage) => {
+ setSelectedAccessPages((current) => {
+ if (page === "all") {
+ return ["all"];
+ }
+ const withoutAll = current.filter((item) => item !== "all");
+ if (withoutAll.includes(page)) {
+ const next = withoutAll.filter((item) => item !== page);
+ return next.length > 0 ? next : ["all"];
+ }
+ return normalizeDeveloperAccessPageSelection([...withoutAll, page]);
+ });
+ };
+
+ return (
+
+ }
+ title={t("ui.dev.nav.developer_grants", "개발자 권한 부여")}
+ description={t(
+ "msg.dev.grants.description",
+ "사용자에게 개발자 권한을 직접 부여하고, 부여된 권한을 회수할 수 있습니다.",
+ )}
+ actions={
+
+ {t("msg.dev.grants.count", "총 {{count}}건", {
+ count: filteredGrantedUsers.length,
+ })}
+
+ }
+ />
+
+
+
+
+ {t("ui.dev.grants.form.title", "직접 부여")}
+
+
+ {t(
+ "msg.dev.grants.form.description",
+ "사용자를 선택하면 현재 소속 정보가 표시되고, 그 사용자에게 개발자 권한을 즉시 부여합니다.",
+ )}
+
+
+
+
+
+
+
+
+ {t("ui.dev.grants.user_section", "사용자 선택")}
+
+
+ {t("ui.dev.grants.input_section", "입력")}
+
+
+
+ {t(
+ "msg.dev.grants.user_section_description",
+ "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다.",
+ )}
+
+
+
+
+
+
+
+ {
+ setSelectedUser(null);
+ setUserSearch(event.target.value);
+ }}
+ />
+
+ {selectedUser && (
+
+ {t(
+ "msg.dev.grants.selected_user",
+ "선택된 사용자: {{user}}",
+ { user: formatUserLabel(selectedUser) },
+ )}
+
+ )}
+
+
+ {userSearch.trim() !== "" && selectedUser == null && (
+
+ {isUserSearchLoading ? (
+
+ {t(
+ "msg.dev.grants.search_loading",
+ "사용자를 찾는 중입니다...",
+ )}
+
+ ) : (userSearchData?.items ?? []).length > 0 ? (
+ (userSearchData?.items ?? []).map((user) => (
+
+ ))
+ ) : (
+
+ {t(
+ "msg.dev.grants.search_empty",
+ "검색 결과가 없습니다.",
+ )}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ {t("ui.dev.grants.selected_info", "선택된 사용자 정보")}
+
+
+ {t("ui.dev.grants.read_only", "읽기 전용")}
+
+
+
+ {t(
+ "msg.dev.grants.selected_info_description",
+ "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다.",
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {developerAccessPageOptions.map((option) => {
+ const checked =
+ option.value === "all"
+ ? selectedAccessPages.includes("all")
+ : selectedAccessPages.includes(option.value);
+ return (
+
+ );
+ })}
+
+
+ {t(
+ "msg.dev.grants.pages_hint",
+ "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.",
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t("ui.dev.grants.admin_notes", "부여 사유")}
+
+
+
+ {t(
+ "msg.dev.grants.admin_notes_description",
+ "직접 부여의 근거를 간단히 남겨 두면 추후 회수와 검토에 도움이 됩니다.",
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("ui.dev.grants.list.title", "부여된 권한")}
+
+
+ {t(
+ "msg.dev.grants.list.description",
+ "현재 부여된 개발자 권한 목록입니다.",
+ )}
+
+
+
+ {isLoadingGrants ? (
+
+ {t("msg.common.loading", "Loading...")}
+
+ ) : grantsError ? (
+
+ {t(
+ "msg.dev.grants.load_error",
+ "개발자 권한 목록을 불러오지 못했습니다.",
+ )}
+
+ ) : filteredGrantedUsers.length === 0 ? (
+
+ {t("msg.dev.grants.empty", "부여된 권한이 없습니다.")}
+
+ ) : (
+
+
+
+ {t("ui.dev.grants.user", "사용자")}
+ {t("ui.dev.grants.tenant", "테넌트")}
+
+ {t("ui.dev.grants.reason", "부여 사유")}
+
+
+ {t("ui.dev.grants.pages", "권한 페이지")}
+
+ {t("ui.dev.grants.status", "상태")}
+ {t("ui.dev.grants.date", "부여일")}
+
+ {t("ui.dev.grants.actions", "관리")}
+
+
+
+
+ {filteredGrantedUsers.map((grant) => (
+
+
+
+
+ {grant.name || grant.email || grant.userId}
+
+
+ {grant.email || grant.userId}
+
+
+ {grant.userId}
+
+
+
+
+
+
+ {grant.organization ||
+ grant.tenantId ||
+ t("ui.common.na", "없음")}
+
+
+ {grant.tenantId || t("ui.common.na", "없음")}
+
+
+
+
+
+ {grant.reason}
+
+ {grant.adminNotes && (
+
+ {grant.adminNotes}
+
+ )}
+
+
+
+ {(grant.accessPages?.length
+ ? normalizeDeveloperAccessPages(grant.accessPages)
+ : ["all"]
+ ).map((page) => (
+
+ {developerAccessPageOptions.find(
+ (option) => option.value === page,
+ )?.label ?? page}
+
+ ))}
+
+
+
+
+ {t("ui.dev.grants.approved", "승인됨")}
+
+
+
+ {new Date(grant.createdAt).toLocaleDateString()}
+
+
+
+
+ setAdminNotes({
+ ...adminNotes,
+ [grant.id]: event.target.value,
+ })
+ }
+ />
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx
index c8bf3a10..cf65298f 100644
--- a/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx
+++ b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx
@@ -141,6 +141,34 @@ async function renderPage() {
}
describe("DeveloperRequestPage", () => {
+ it("shows selected access pages in the request list", async () => {
+ fetchDeveloperRequestsMock.mockResolvedValueOnce([
+ {
+ id: 1,
+ userId: "user-1",
+ tenantId: "tenant-1",
+ name: "Requester",
+ organization: "Hanmac",
+ email: "requester@example.com",
+ phone: "010-1234-5678",
+ role: "user",
+ reason: "Need RP access",
+ accessPages: ["overview", "audit"],
+ status: "pending",
+ createdAt: "2026-06-09T00:00:00Z",
+ updatedAt: "2026-06-09T00:00:00Z",
+ },
+ ]);
+
+ const container = await renderPage();
+ const pageCell = container.querySelector(
+ "table tbody tr td:nth-child(3)",
+ ) as HTMLTableCellElement | null;
+ expect(pageCell?.textContent).toContain("개요");
+ expect(pageCell?.textContent).toContain("감사로그");
+ expect(pageCell?.textContent).not.toContain("전체");
+ });
+
it("opens the request modal and submits a request", async () => {
const container = await renderPage();
expect(container.textContent).toContain("신규 신청하기");
@@ -183,6 +211,115 @@ describe("DeveloperRequestPage", () => {
organization: "Hanmac",
reason: "Need RP access",
tenantId: "tenant-1",
+ accessPages: ["all"],
});
});
+
+ it("allows requesting developer access even when tenant context is missing", async () => {
+ authState = {
+ user: {
+ access_token: "access-token",
+ profile: {
+ role: "user",
+ companyCode: "HANMAC",
+ name: "Requester",
+ email: "requester@example.com",
+ phone: "010-1234-5678",
+ },
+ },
+ };
+ fetchMeMock.mockResolvedValue({
+ id: "user-1",
+ name: "Requester",
+ email: "requester@example.com",
+ phone: "010-1234-5678",
+ role: "user",
+ });
+ fetchMyTenantsMock.mockResolvedValue([]);
+
+ const container = await renderPage();
+ expect(container.textContent).toContain("신규 신청하기");
+ expect(container.textContent).not.toContain(
+ "개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
+ );
+
+ const actionButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("신규 신청하기"),
+ );
+ expect(actionButton).toBeTruthy();
+
+ await act(async () => {
+ actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(container.textContent).toContain("개발자 등록 신청");
+
+ const reasonField = container.querySelector(
+ "textarea",
+ ) as HTMLTextAreaElement | null;
+ if (!reasonField) {
+ throw new Error("Expected reason textarea to be rendered");
+ }
+
+ await act(async () => {
+ await setTextAreaValue(reasonField, "Need RP access");
+ });
+
+ const submitButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent === "신청하기",
+ );
+ expect(submitButton).toBeTruthy();
+
+ await act(async () => {
+ submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(requestDeveloperAccessMock.mock.calls[0]?.[0]).toEqual({
+ name: "Requester",
+ organization: "HANMAC",
+ reason: "Need RP access",
+ tenantId: "",
+ accessPages: ["all"],
+ });
+ });
+
+ it("shows '없음' when organization is unavailable", async () => {
+ authState = {
+ user: {
+ access_token: "access-token",
+ profile: {
+ role: "user",
+ name: "Requester",
+ email: "requester@example.com",
+ phone: "010-1234-5678",
+ },
+ },
+ };
+ fetchMeMock.mockResolvedValue({
+ id: "user-1",
+ name: "Requester",
+ email: "requester@example.com",
+ phone: "010-1234-5678",
+ role: "user",
+ });
+ fetchMyTenantsMock.mockResolvedValue([]);
+
+ const container = await renderPage();
+ const actionButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("신규 신청하기"),
+ );
+ expect(actionButton).toBeTruthy();
+
+ await act(async () => {
+ actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ const orgField = container.querySelector("#org") as HTMLInputElement | null;
+ if (!orgField) {
+ throw new Error("Expected organization input to be rendered");
+ }
+
+ expect(orgField.value).toBe("없음");
+ });
});
diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.tsx
index 72b2b6de..5f11a631 100644
--- a/devfront/src/features/developer-request/DeveloperRequestPage.tsx
+++ b/devfront/src/features/developer-request/DeveloperRequestPage.tsx
@@ -47,6 +47,12 @@ import {
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
+import {
+ type DeveloperAccessPage,
+ developerAccessPageOptions,
+ normalizeDeveloperAccessPageSelection,
+ normalizeDeveloperAccessPages,
+} from "../developer-access/developerAccessPages";
export default function DeveloperRequestPage() {
const auth = useAuth();
@@ -152,9 +158,7 @@ export default function DeveloperRequestPage() {
);
}
- const hasActiveRequest = requests?.some(
- (r) => r.status === "pending" || r.status === "approved",
- );
+ const hasActiveRequest = requests?.some((r) => r.status === "pending");
const approvedRequestCount =
requests?.filter((request) => request.status === "approved").length ?? 0;
const isActionPending =
@@ -218,6 +222,9 @@ export default function DeveloperRequestPage() {
{t("ui.dev.request.table.reason", "신청 사유")}
+
+ {t("ui.dev.request.table.pages", "권한 페이지")}
+
{t("ui.dev.request.table.status", "상태")}
@@ -235,7 +242,7 @@ export default function DeveloperRequestPage() {
{!requests || requests.length === 0 ? (
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
@@ -259,7 +266,10 @@ export default function DeveloperRequestPage() {
)}
)}
- {req.organization}
+
+ {req.organization?.trim() ||
+ t("ui.common.na", "없음")}
+
{req.reason}
@@ -270,6 +280,25 @@ export default function DeveloperRequestPage() {
)}
+
+
+ {req.accessPages?.length ? (
+ normalizeDeveloperAccessPages(
+ req.accessPages,
+ ).map((page) => (
+
+ {developerAccessPageOptions.find(
+ (option) => option.value === page,
+ )?.label ?? page}
+
+ ))
+ ) : (
+
+ {t("ui.common.na", "없음")}
+
+ )}
+
+
@@ -447,11 +476,16 @@ function RequestAccessModal({
const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState("");
+ const [accessPages, setAccessPages] = useState([
+ "all",
+ ]);
+ const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
useEffect(() => {
if (!isOpen) return;
setName(initialName);
setOrganization(initialOrg);
+ setAccessPages(["all"]);
}, [initialName, initialOrg, isOpen]);
const mutation = useMutation({
@@ -468,6 +502,21 @@ function RequestAccessModal({
organization,
reason,
tenantId,
+ accessPages: normalizeDeveloperAccessPageSelection(accessPages),
+ });
+ };
+
+ const handleAccessPageToggle = (page: DeveloperAccessPage) => {
+ setAccessPages((current) => {
+ if (page === "all") {
+ return ["all"];
+ }
+ const withoutAll = current.filter((item) => item !== "all");
+ if (withoutAll.includes(page)) {
+ const next = withoutAll.filter((item) => item !== page);
+ return next.length > 0 ? next : ["all"];
+ }
+ return normalizeDeveloperAccessPageSelection([...withoutAll, page]);
});
};
@@ -518,7 +567,7 @@ function RequestAccessModal({
+
+
+
+ {developerAccessPageOptions.map((option) => {
+ const checked =
+ option.value === "all"
+ ? accessPages.includes("all")
+ : accessPages.includes(option.value);
+ return (
+
+ );
+ })}
+
+
+ {t(
+ "msg.dev.request.modal.pages_hint",
+ "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.",
+ )}
+
+
|