forked from baron/baron-sso
개발자 권한을 페이지별로 선택/부여 가능하도록 개선
This commit is contained in:
@@ -101,6 +101,7 @@ function AuditLogsPage() {
|
||||
hasAccessToken,
|
||||
profileRole,
|
||||
tenantId,
|
||||
requiredPages: ["audit"],
|
||||
isLoadingIdentity: isLoadingMe,
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import type {
|
||||
ClientStatus,
|
||||
@@ -54,6 +55,7 @@ import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { fetchMe, type UserProfile } from "../auth/authApi";
|
||||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
|
||||
|
||||
@@ -358,16 +360,27 @@ function ClientGeneralPage() {
|
||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||
const clientId = params.id;
|
||||
const isCreate = !clientId;
|
||||
const systemRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const { data: me } = useQuery<UserProfile>({
|
||||
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||
const systemRole = resolveProfileRole(userProfile);
|
||||
const { data: me, isLoading: isLoadingMe } = useQuery<UserProfile>({
|
||||
queryKey: ["userMe"],
|
||||
queryFn: fetchMe,
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
const currentUserId = me?.id ?? auth.user?.profile.sub;
|
||||
const effectiveSystemRole = me?.role?.trim() || systemRole;
|
||||
const {
|
||||
hasDeveloperAccess: hasClientCreateAccess,
|
||||
isDeveloperRequestPending,
|
||||
canRequestDeveloperAccess,
|
||||
isLoadingDeveloperAccessGate,
|
||||
} = useDeveloperAccessGate({
|
||||
hasAccessToken,
|
||||
profileRole: effectiveSystemRole,
|
||||
tenantId: userProfile?.tenant_id as string | undefined,
|
||||
requiredPages: ["client_create"],
|
||||
isLoadingIdentity: isLoadingMe,
|
||||
});
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId as string),
|
||||
@@ -1161,10 +1174,44 @@ function ClientGeneralPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isCreate && isLoading) {
|
||||
if ((isCreate && isLoadingDeveloperAccessGate) || (!isCreate && isLoading)) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.general.loading", "Loading client...")}
|
||||
{t(
|
||||
"msg.dev.clients.general.loading",
|
||||
isCreate ? "Loading client creation..." : "Loading client...",
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isCreate && !hasClientCreateAccess) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<DeveloperAccessRequestCard
|
||||
title={t("ui.dev.clients.general.title_create", "Create Client")}
|
||||
isPending={isDeveloperRequestPending}
|
||||
canRequest={canRequestDeveloperAccess}
|
||||
pendingMessage={t(
|
||||
"msg.dev.clients.general.create_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)}
|
||||
deniedMessage={t(
|
||||
"msg.dev.clients.general.create_forbidden",
|
||||
"이 RP를 생성할 권한이 없습니다.",
|
||||
)}
|
||||
pendingDetailMessage={t(
|
||||
"msg.dev.clients.general.create_pending_detail",
|
||||
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
|
||||
)}
|
||||
deniedDetailMessage={t(
|
||||
"msg.dev.clients.general.create_forbidden_detail",
|
||||
"개발자 권한 신청에서 연동 앱 추가 권한을 선택한 뒤 승인받아주세요.",
|
||||
)}
|
||||
actionLabel={t("ui.dev.welcome.btn_request", "개발자 등록 신청하기")}
|
||||
onAction={() => navigate("/developer-requests")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
toggleSort,
|
||||
} from "../../../../common/core/utils";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
|
||||
import {
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
@@ -53,6 +54,7 @@ import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||
import { resolveClientCreateAccess } from "./clientCreateAccess";
|
||||
import { ClientLogo } from "./components/ClientLogo";
|
||||
|
||||
@@ -101,13 +103,26 @@ function ClientsPage() {
|
||||
enabled: hasAccessToken,
|
||||
});
|
||||
|
||||
const {
|
||||
hasDeveloperAccess: hasClientsPageAccess,
|
||||
isDeveloperRequestPending,
|
||||
canRequestDeveloperAccess,
|
||||
isLoadingDeveloperAccessGate,
|
||||
} = useDeveloperAccessGate({
|
||||
hasAccessToken,
|
||||
profileRole,
|
||||
tenantId,
|
||||
requiredPages: ["client_create"],
|
||||
isLoadingIdentity: isLoadingMe,
|
||||
});
|
||||
|
||||
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("");
|
||||
@@ -189,6 +204,7 @@ function ClientsPage() {
|
||||
const isLoading =
|
||||
isLoadingClients ||
|
||||
isLoadingRequest ||
|
||||
isLoadingDeveloperAccessGate ||
|
||||
(hasAccessToken && !profileRole && isLoadingMe);
|
||||
|
||||
const requestSort = (key: ClientSortKey) => {
|
||||
@@ -203,6 +219,38 @@ function ClientsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasClientsPageAccess) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<DeveloperAccessRequestCard
|
||||
title={t("ui.dev.clients.registry.subtitle", "연동 앱")}
|
||||
isPending={isDeveloperRequestPending}
|
||||
canRequest={canRequestDeveloperAccess}
|
||||
pendingMessage={t(
|
||||
"msg.dev.dashboard.access_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)}
|
||||
deniedMessage={t(
|
||||
"msg.dev.clients.access_denied",
|
||||
"연동 앱 페이지에 접근할 권한이 없습니다.",
|
||||
)}
|
||||
pendingDetailMessage={t(
|
||||
"msg.dev.dashboard.access_pending_detail",
|
||||
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
|
||||
)}
|
||||
deniedDetailMessage={t(
|
||||
"msg.dev.clients.access_denied_detail",
|
||||
"개발자 권한 신청에서 개요 또는 연동 앱 추가 권한을 선택한 뒤 승인받아주세요.",
|
||||
)}
|
||||
actionLabel={t("ui.dev.welcome.btn_request", "개발자 등록 신청하기")}
|
||||
onAction={() => navigate("/developer-requests")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (clientError) {
|
||||
const axiosError = clientError as AxiosError<{ error?: string }>;
|
||||
if (axiosError.response?.status === 403) {
|
||||
@@ -255,7 +303,7 @@ function ClientsPage() {
|
||||
{t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||
</Button>
|
||||
</div>
|
||||
) : canRequestDeveloperAccess ? (
|
||||
) : canRequestClientCreateAccess ? (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<p className="max-w-xs whitespace-pre-line text-right text-sm text-muted-foreground">
|
||||
{t(
|
||||
@@ -458,11 +506,11 @@ function ClientsPage() {
|
||||
"msg.dev.clients.empty_can_create",
|
||||
"아직 등록된 연동 앱이 없습니다.",
|
||||
)
|
||||
: isDeveloperRequestPending
|
||||
? t(
|
||||
"msg.dev.clients.empty_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)
|
||||
: isClientCreatePending
|
||||
? t(
|
||||
"msg.dev.clients.empty_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)
|
||||
: t(
|
||||
"msg.dev.clients.empty",
|
||||
"조회 가능한 RP가 없습니다.",
|
||||
@@ -480,7 +528,7 @@ function ClientsPage() {
|
||||
"msg.dev.clients.empty_can_create_detail",
|
||||
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
|
||||
)
|
||||
: isDeveloperRequestPending
|
||||
: isClientCreatePending
|
||||
? t(
|
||||
"msg.dev.clients.empty_pending_detail",
|
||||
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
|
||||
@@ -499,7 +547,7 @@ function ClientsPage() {
|
||||
{t("ui.dev.clients.new", "연동 앱 추가")}
|
||||
</button>
|
||||
)}
|
||||
{!isFilteredOut && canRequestDeveloperAccess && (
|
||||
{!isFilteredOut && canRequestClientCreateAccess && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary font-bold hover:underline"
|
||||
@@ -693,6 +741,7 @@ function RequestAccessModal({
|
||||
organization,
|
||||
reason,
|
||||
tenantId,
|
||||
accessPages: ["all"],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -32,7 +32,7 @@ describe("client create access", () => {
|
||||
expect(
|
||||
resolveClientCreateAccess({
|
||||
role: "user",
|
||||
requestStatus: "pending",
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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,7 +12,7 @@ export type ClientCreateAccessState =
|
||||
|
||||
type ResolveClientCreateAccessParams = {
|
||||
role: string;
|
||||
requestStatus?: DeveloperRequestStatus;
|
||||
accessStatus?: DeveloperAccessStatus;
|
||||
};
|
||||
|
||||
function canSelfRequestDeveloperAccess(role: string) {
|
||||
@@ -17,7 +21,7 @@ function canSelfRequestDeveloperAccess(role: string) {
|
||||
|
||||
export function resolveClientCreateAccess({
|
||||
role,
|
||||
requestStatus,
|
||||
accessStatus,
|
||||
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
|
||||
if (!role.trim()) {
|
||||
return "request_required";
|
||||
@@ -27,22 +31,17 @@ 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";
|
||||
}
|
||||
|
||||
@@ -12,25 +12,51 @@ describe("developer access gate", () => {
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
type DeveloperRequestStatus,
|
||||
type DeveloperAccessStatus,
|
||||
fetchDeveloperRequestStatus,
|
||||
} from "../../lib/devApi";
|
||||
import {
|
||||
hasDeveloperAccessForPages,
|
||||
isDeveloperRequestPendingForPages,
|
||||
type DeveloperAccessPage,
|
||||
} from "./developerAccessPages";
|
||||
|
||||
export type DeveloperAccessGateState = {
|
||||
hasDeveloperAccess: boolean;
|
||||
@@ -14,16 +19,23 @@ export type DeveloperAccessGateState = {
|
||||
|
||||
export function resolveDeveloperAccessGate(
|
||||
profileRole: string,
|
||||
requestStatus?: DeveloperRequestStatus,
|
||||
accessStatus?: DeveloperAccessStatus,
|
||||
requiredPages: DeveloperAccessPage[] = ["overview"],
|
||||
): Omit<
|
||||
DeveloperAccessGateState,
|
||||
"isLoadingDeveloperAccessGate" | "isTenantContextMissing"
|
||||
> {
|
||||
const hasDeveloperAccess =
|
||||
profileRole === "super_admin" || 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;
|
||||
profileRole === "user" &&
|
||||
!hasDeveloperAccess &&
|
||||
!isDeveloperRequestPending;
|
||||
|
||||
return {
|
||||
hasDeveloperAccess,
|
||||
@@ -50,11 +62,13 @@ export function useDeveloperAccessGate({
|
||||
hasAccessToken,
|
||||
profileRole,
|
||||
tenantId,
|
||||
requiredPages = ["overview"],
|
||||
isLoadingIdentity = false,
|
||||
}: {
|
||||
hasAccessToken: boolean;
|
||||
profileRole: string;
|
||||
tenantId?: string;
|
||||
requiredPages?: DeveloperAccessPage[];
|
||||
isLoadingIdentity?: boolean;
|
||||
}) {
|
||||
const shouldFetchRequestStatus =
|
||||
@@ -68,7 +82,8 @@ export function useDeveloperAccessGate({
|
||||
|
||||
const resolvedGate = resolveDeveloperAccessGate(
|
||||
profileRole,
|
||||
requestStatus?.status,
|
||||
requestStatus,
|
||||
requiredPages,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
104
devfront/src/features/developer-access/developerAccessPages.ts
Normal file
104
devfront/src/features/developer-access/developerAccessPages.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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<string | undefined | null>,
|
||||
): DeveloperAccessPage[] {
|
||||
const normalized = new Set<DeveloperAccessPage>();
|
||||
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<string | null>) {
|
||||
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<string | null> | 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<string | null> | undefined,
|
||||
requiredPages: DeveloperAccessPage[],
|
||||
) {
|
||||
const normalized = normalizeDeveloperAccessPages(pendingPages ?? []);
|
||||
if (normalized.includes("all")) {
|
||||
return true;
|
||||
}
|
||||
return requiredPages.some((page) => normalized.includes(page));
|
||||
}
|
||||
@@ -36,6 +36,12 @@ import {
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
import {
|
||||
developerAccessPageOptions,
|
||||
normalizeDeveloperAccessPages,
|
||||
normalizeDeveloperAccessPageSelection,
|
||||
type DeveloperAccessPage,
|
||||
} from "../developer-access/developerAccessPages";
|
||||
|
||||
function formatUserLabel(user: DevAssignableUser) {
|
||||
const primary = user.name.trim() || user.email.trim();
|
||||
@@ -62,6 +68,9 @@ export default function DeveloperGrantsPage() {
|
||||
const [selectedUser, setSelectedUser] = useState<DevAssignableUser | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedAccessPages, setSelectedAccessPages] = useState<
|
||||
DeveloperAccessPage[]
|
||||
>(["all"]);
|
||||
const [grantNotes, setGrantNotes] = useState("");
|
||||
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
|
||||
|
||||
@@ -122,6 +131,7 @@ export default function DeveloperGrantsPage() {
|
||||
);
|
||||
setSelectedUser(null);
|
||||
setUserSearch("");
|
||||
setSelectedAccessPages(["all"]);
|
||||
setGrantNotes("");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }> | Error) => {
|
||||
@@ -212,12 +222,28 @@ export default function DeveloperGrantsPage() {
|
||||
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 (
|
||||
@@ -430,6 +456,40 @@ export default function DeveloperGrantsPage() {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("ui.dev.grants.pages", "권한 페이지")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="grid gap-2 rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
{developerAccessPageOptions.map((option) => {
|
||||
const checked =
|
||||
option.value === "all"
|
||||
? selectedAccessPages.includes("all")
|
||||
: selectedAccessPages.includes(option.value);
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-background/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleAccessPageToggle(option.value)}
|
||||
/>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.grants.pages_hint",
|
||||
"전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -518,6 +578,7 @@ export default function DeveloperGrantsPage() {
|
||||
<TableHead>{t("ui.dev.grants.user", "사용자")}</TableHead>
|
||||
<TableHead>{t("ui.dev.grants.tenant", "테넌트")}</TableHead>
|
||||
<TableHead>{t("ui.dev.grants.reason", "부여 사유")}</TableHead>
|
||||
<TableHead>{t("ui.dev.grants.pages", "권한 페이지")}</TableHead>
|
||||
<TableHead>{t("ui.dev.grants.status", "상태")}</TableHead>
|
||||
<TableHead>{t("ui.dev.grants.date", "부여일")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
@@ -536,7 +597,7 @@ export default function DeveloperGrantsPage() {
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{grant.email || grant.userId}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{grant.userId}
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,6 +622,20 @@ export default function DeveloperGrantsPage() {
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(grant.accessPages?.length
|
||||
? normalizeDeveloperAccessPages(grant.accessPages)
|
||||
: ["all"]
|
||||
).map((page) => (
|
||||
<Badge key={page} variant="outline">
|
||||
{developerAccessPageOptions.find(
|
||||
(option) => option.value === page,
|
||||
)?.label ?? page}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="success">
|
||||
{t("ui.dev.grants.approved", "승인됨")}
|
||||
|
||||
@@ -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,7 @@ describe("DeveloperRequestPage", () => {
|
||||
organization: "Hanmac",
|
||||
reason: "Need RP access",
|
||||
tenantId: "tenant-1",
|
||||
accessPages: ["all"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,6 +280,7 @@ describe("DeveloperRequestPage", () => {
|
||||
organization: "HANMAC",
|
||||
reason: "Need RP access",
|
||||
tenantId: "",
|
||||
accessPages: ["all"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ import {
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
import {
|
||||
developerAccessPageOptions,
|
||||
normalizeDeveloperAccessPages,
|
||||
normalizeDeveloperAccessPageSelection,
|
||||
type DeveloperAccessPage,
|
||||
} from "../developer-access/developerAccessPages";
|
||||
|
||||
export default function DeveloperRequestPage() {
|
||||
const auth = useAuth();
|
||||
@@ -153,7 +159,7 @@ export default function DeveloperRequestPage() {
|
||||
}
|
||||
|
||||
const hasActiveRequest = requests?.some(
|
||||
(r) => r.status === "pending" || r.status === "approved",
|
||||
(r) => r.status === "pending",
|
||||
);
|
||||
const approvedRequestCount =
|
||||
requests?.filter((request) => request.status === "approved").length ?? 0;
|
||||
@@ -218,6 +224,9 @@ export default function DeveloperRequestPage() {
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.reason", "신청 사유")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.pages", "권한 페이지")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.request.table.status", "상태")}
|
||||
</TableHead>
|
||||
@@ -235,7 +244,7 @@ export default function DeveloperRequestPage() {
|
||||
{!requests || requests.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isSuperAdmin ? 6 : 4}
|
||||
colSpan={isSuperAdmin ? 7 : 5}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
|
||||
@@ -272,6 +281,25 @@ export default function DeveloperRequestPage() {
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{req.accessPages?.length ? (
|
||||
normalizeDeveloperAccessPages(req.accessPages).map(
|
||||
(page) => (
|
||||
<Badge key={page} variant="outline">
|
||||
{developerAccessPageOptions.find(
|
||||
(option) => option.value === page,
|
||||
)?.label ?? page}
|
||||
</Badge>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{t("ui.common.na", "없음")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={req.status} />
|
||||
</TableCell>
|
||||
@@ -449,12 +477,16 @@ function RequestAccessModal({
|
||||
const [name, setName] = useState(initialName);
|
||||
const [organization, setOrganization] = useState(initialOrg);
|
||||
const [reason, setReason] = useState("");
|
||||
const [accessPages, setAccessPages] = useState<DeveloperAccessPage[]>([
|
||||
"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({
|
||||
@@ -471,6 +503,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]);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -562,6 +609,39 @@ function RequestAccessModal({
|
||||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label>
|
||||
{t("ui.dev.request.modal.pages", "권한 페이지")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="grid gap-2 rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
{developerAccessPageOptions.map((option) => {
|
||||
const checked =
|
||||
option.value === "all"
|
||||
? accessPages.includes("all")
|
||||
: accessPages.includes(option.value);
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-background/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleAccessPageToggle(option.value)}
|
||||
/>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.request.modal.pages_hint",
|
||||
"전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reason">
|
||||
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
|
||||
|
||||
@@ -972,6 +972,7 @@ function GlobalOverviewPage() {
|
||||
hasAccessToken,
|
||||
profileRole,
|
||||
tenantId,
|
||||
requiredPages: ["overview"],
|
||||
isLoadingIdentity: isLoadingMe,
|
||||
});
|
||||
const distribution = useMemo(
|
||||
|
||||
@@ -173,6 +173,7 @@ describe("devApi", () => {
|
||||
organization: "Hanmac",
|
||||
reason: "Need RP access",
|
||||
tenantId: "tenant-a",
|
||||
accessPages: ["all"],
|
||||
});
|
||||
await approveDeveloperRequest(1, "approved");
|
||||
await rejectDeveloperRequest(2, "rejected");
|
||||
@@ -238,6 +239,7 @@ describe("devApi", () => {
|
||||
organization: "Hanmac",
|
||||
reason: "Need RP access",
|
||||
tenantId: "tenant-a",
|
||||
accessPages: ["all"],
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/dev/developer-request/1/approve",
|
||||
|
||||
@@ -530,6 +530,7 @@ export type DeveloperRequest = {
|
||||
phone?: string;
|
||||
role?: string;
|
||||
reason: string;
|
||||
accessPages?: string[];
|
||||
status: DeveloperRequestStatus;
|
||||
adminNotes?: string;
|
||||
createdAt: string;
|
||||
@@ -538,8 +539,14 @@ export type DeveloperRequest = {
|
||||
|
||||
export type DeveloperGrant = DeveloperRequest;
|
||||
|
||||
export type DeveloperAccessStatus = {
|
||||
status: DeveloperRequestStatus | "none";
|
||||
approvedPages?: string[];
|
||||
pendingPages?: string[];
|
||||
};
|
||||
|
||||
export async function fetchDeveloperRequestStatus(tenantId?: string) {
|
||||
const { data } = await apiClient.get<DeveloperRequest | { status: "none" }>(
|
||||
const { data } = await apiClient.get<DeveloperAccessStatus>(
|
||||
"/dev/developer-request/status",
|
||||
{
|
||||
params: { tenantId },
|
||||
@@ -553,6 +560,7 @@ export async function requestDeveloperAccess(payload: {
|
||||
organization: string;
|
||||
reason: string;
|
||||
tenantId: string;
|
||||
accessPages: string[];
|
||||
}) {
|
||||
const { data } = await apiClient.post<{ status: string }>(
|
||||
"/dev/developer-request",
|
||||
@@ -610,6 +618,7 @@ export async function createDeveloperGrant(payload: {
|
||||
tenantId: string;
|
||||
reason?: string;
|
||||
adminNotes?: string;
|
||||
accessPages: string[];
|
||||
}) {
|
||||
const { data } = await apiClient.post<DeveloperGrant>(
|
||||
"/dev/developer-grants",
|
||||
|
||||
Reference in New Issue
Block a user