1
0
forked from baron/baron-sso

테넌트 비소속 개발자 권한 신청/부여 가능

This commit is contained in:
2026-06-09 11:40:33 +09:00
parent 0f11173739
commit 3ed9e912e6
12 changed files with 208 additions and 188 deletions

View File

@@ -174,20 +174,20 @@ describe("AuditLogsPage", () => {
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
it("shows a tenant-required notice when tenant context is missing", async () => {
it("renders the generic access request card when tenant context is missing", async () => {
gateState = {
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
canRequestDeveloperAccess: true,
isLoadingDeveloperAccessGate: false,
isTenantContextMissing: true,
};
const container = await renderPage();
expect(container.textContent).toContain(
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
);
expect(container.textContent).not.toContain("개발자 권한 신청");
expect(container.textContent).toContain("개발자 권한 신청");
});
it("exports the fetched logs as CSV", async () => {

View File

@@ -97,7 +97,6 @@ function AuditLogsPage() {
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
isTenantContextMissing,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole,
@@ -143,24 +142,6 @@ function AuditLogsPage() {
}
if (!hasDeveloperAccess) {
const deniedMessage = isTenantContextMissing
? t(
"msg.dev.request.tenant_required",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
)
: t(
"msg.dev.audit.access_denied",
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
);
const deniedDetailMessage = isTenantContextMissing
? t(
"msg.dev.request.tenant_required_detail",
"현재 계정은 테넌트와 연결되어 있지 않아 개발자 권한을 신청할 수 없습니다.",
)
: t(
"msg.dev.audit.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
);
return (
<DeveloperAccessRequestCard
title={t("ui.common.audit.title", "Audit Logs")}
@@ -170,12 +151,18 @@ function AuditLogsPage() {
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={deniedMessage}
deniedMessage={t(
"msg.dev.audit.access_denied",
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
)}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedDetailMessage={deniedDetailMessage}
deniedDetailMessage={t(
"msg.dev.audit.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
onAction={() => navigate("/developer-requests")}
/>

View File

@@ -278,7 +278,7 @@ describe("ClientsPage", () => {
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
it("shows a tenant-required notice when tenant context is missing", async () => {
it("allows a user without tenant context to request developer access", async () => {
authState = {
user: {
access_token: "access-token",
@@ -297,11 +297,56 @@ describe("ClientsPage", () => {
email: "requester@example.com",
phone: "010-1234-5678",
});
fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" });
const container = await renderPage();
expect(container.textContent).toContain(
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
expect(container.textContent).toContain("개발자 등록 신청하기");
const requestButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "개발자 등록 신청하기",
);
expect(fetchDeveloperRequestStatusMock).not.toHaveBeenCalled();
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");
});
});

View File

@@ -67,7 +67,6 @@ function ClientsPage() {
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const companyCode = userProfile?.companyCode as string | undefined;
const isTenantContextMissing = !tenantId?.trim();
const {
data,
@@ -94,8 +93,7 @@ function ClientsPage() {
} = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled:
hasAccessToken && profileRole === "user" && !isTenantContextMissing,
enabled: hasAccessToken && profileRole === "user",
});
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
@@ -110,9 +108,7 @@ function ClientsPage() {
const canCreateClient = createAccessState === "can_create";
const isDeveloperRequestPending = createAccessState === "pending";
const canRequestDeveloperAccess =
createAccessState === "request_required" &&
!isLoadingRequest &&
!isTenantContextMissing;
createAccessState === "request_required" && !isLoadingRequest;
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
@@ -233,20 +229,7 @@ function ClientsPage() {
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
)}
actions={
isTenantContextMissing ? (
<div className="flex flex-col items-end gap-2 text-right">
<p className="max-w-xs text-sm text-muted-foreground">
{t(
"msg.dev.clients.create_requires_tenant",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
)}
</p>
<Button type="button" variant="outline" size="sm" disabled>
<Plus className="h-4 w-4" />
{t("ui.dev.welcome.btn_request", "개발자 권한 신청")}
</Button>
</div>
) : canCreateClient ? (
canCreateClient ? (
<Button
size="sm"
className="mt-1 shadow-lg shadow-primary/30"
@@ -470,11 +453,6 @@ function ClientsPage() {
"msg.dev.clients.empty_filtered",
"조건에 맞는 연동 앱이 없습니다.",
)
: isTenantContextMissing
? t(
"msg.dev.clients.empty_tenant_missing",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create",
@@ -497,11 +475,6 @@ function ClientsPage() {
"msg.dev.clients.empty_filtered_detail",
"검색어나 필터 조건을 변경해 보세요.",
)
: isTenantContextMissing
? t(
"msg.dev.clients.empty_tenant_missing_detail",
"현재 계정은 테넌트와 연결되어 있지 않아 개발자 권한을 신청할 수 없습니다.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create_detail",
@@ -526,18 +499,6 @@ function ClientsPage() {
{t("ui.dev.clients.new", "연동 앱 추가")}
</button>
)}
{!isFilteredOut && isTenantContextMissing && (
<button
type="button"
className="font-bold text-muted-foreground"
disabled
>
{t(
"ui.dev.welcome.btn_request",
"개발자 등록 신청하기",
)}
</button>
)}
{!isFilteredOut && canRequestDeveloperAccess && (
<button
type="button"
@@ -711,7 +672,6 @@ function RequestAccessModal({
const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState("");
const isTenantContextMissing = !tenantId.trim();
useEffect(() => {
if (!isOpen) return;
@@ -728,15 +688,6 @@ function RequestAccessModal({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isTenantContextMissing) {
alert(
t(
"msg.dev.clients.create_requires_tenant",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
),
);
return;
}
mutation.mutate({
name,
organization,

View File

@@ -63,8 +63,7 @@ export function useDeveloperAccessGate({
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled:
hasAccessToken && shouldFetchRequestStatus && !isTenantContextMissing,
enabled: hasAccessToken && shouldFetchRequestStatus,
});
const resolvedGate = resolveDeveloperAccessGate(
@@ -75,8 +74,7 @@ export function useDeveloperAccessGate({
return {
...resolvedGate,
isTenantContextMissing,
canRequestDeveloperAccess:
resolvedGate.canRequestDeveloperAccess && !isTenantContextMissing,
canRequestDeveloperAccess: resolvedGate.canRequestDeveloperAccess,
isLoadingDeveloperAccessGate: shouldShowDeveloperAccessLoading(
profileRole,
isLoadingIdentity,

View File

@@ -201,17 +201,11 @@ export default function DeveloperGrantsPage() {
);
return;
}
const tenantId = selectedUserDetail?.tenant?.id?.trim() || "";
if (!tenantId) {
toast(
t(
"msg.dev.grants.tenant_required",
"선택한 사용자의 현재 테넌트 정보를 확인할 수 없습니다.",
),
"error",
);
return;
}
const tenantId =
selectedUserDetail?.tenant?.id?.trim() ||
selectedUserDetail?.tenantSlug?.trim() ||
selectedUserDetail?.companyCode?.trim() ||
"";
createGrantMutation.mutate({
userId: selectedUser.id,
@@ -382,7 +376,9 @@ export default function DeveloperGrantsPage() {
selectedUserDetail?.tenant?.name ||
selectedUserDetail?.tenantSlug ||
selectedUserDetail?.companyCode ||
""
(selectedUser && !isSelectedUserDetailLoading
? t("ui.common.na", "없음")
: "")
}
readOnly
placeholder={
@@ -548,10 +544,10 @@ export default function DeveloperGrantsPage() {
<TableCell>
<div className="space-y-1">
<div className="font-medium">
{grant.organization || grant.tenantId}
{grant.organization || grant.tenantId || t("ui.common.na", "없음")}
</div>
<div className="font-mono text-xs text-muted-foreground">
{grant.tenantId}
{grant.tenantId || t("ui.common.na", "없음")}
</div>
</div>
</TableCell>

View File

@@ -186,7 +186,7 @@ describe("DeveloperRequestPage", () => {
});
});
it("shows a tenant-required notice and hides the request button when tenant is missing", async () => {
it("allows requesting developer access even when tenant context is missing", async () => {
authState = {
user: {
access_token: "access-token",
@@ -206,12 +206,90 @@ describe("DeveloperRequestPage", () => {
phone: "010-1234-5678",
role: "user",
});
fetchMyTenantsMock.mockResolvedValue([]);
const container = await renderPage();
expect(container.textContent).toContain(
expect(container.textContent).toContain("신규 신청하기");
expect(container.textContent).not.toContain(
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
);
expect(container.textContent).not.toContain("신규 신청하기");
expect(requestDeveloperAccessMock).not.toHaveBeenCalled();
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: "",
});
});
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("없음");
});
});

View File

@@ -56,7 +56,6 @@ export default function DeveloperRequestPage() {
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const companyCode = userProfile?.companyCode as string | undefined;
const isTenantContextMissing = !tenantId?.trim();
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
@@ -180,7 +179,7 @@ export default function DeveloperRequestPage() {
)
}
actions={
!isSuperAdmin && !isTenantContextMissing && !hasActiveRequest ? (
!isSuperAdmin && !hasActiveRequest ? (
<Button onClick={() => setIsRequestModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
@@ -189,28 +188,6 @@ export default function DeveloperRequestPage() {
}
/>
{!isSuperAdmin && isTenantContextMissing ? (
<Card className="border-amber-500/30 bg-amber-500/10">
<CardContent className="flex items-start gap-3 p-4">
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<div className="space-y-1 text-left">
<p className="font-semibold text-foreground">
{t(
"msg.dev.request.tenant_required",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
)}
</p>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.request.tenant_required_detail",
"현재 계정은 테넌트와 연결되어 있지 않아 개발자 권한을 신청할 수 없습니다.",
)}
</p>
</div>
</CardContent>
</Card>
) : null}
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-xl">
@@ -282,7 +259,9 @@ export default function DeveloperRequestPage() {
)}
</TableCell>
)}
<TableCell>{req.organization}</TableCell>
<TableCell>
{req.organization?.trim() || t("ui.common.na", "없음")}
</TableCell>
<TableCell className="max-w-md">
<div className="truncate" title={req.reason}>
{req.reason}
@@ -470,6 +449,7 @@ function RequestAccessModal({
const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState("");
const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
useEffect(() => {
if (!isOpen) return;
@@ -486,15 +466,6 @@ function RequestAccessModal({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!tenantId.trim()) {
alert(
t(
"msg.dev.request.tenant_required",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
),
);
return;
}
mutation.mutate({
name,
organization,
@@ -550,7 +521,7 @@ function RequestAccessModal({
</Label>
<Input
id="org"
value={organization}
value={organizationDisplay}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required

View File

@@ -968,7 +968,6 @@ function GlobalOverviewPage() {
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
isTenantContextMissing,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole,
@@ -1267,24 +1266,6 @@ function GlobalOverviewPage() {
}
if (!hasDeveloperAccess) {
const deniedMessage = isTenantContextMissing
? t(
"msg.dev.request.tenant_required",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
)
: t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
);
const deniedDetailMessage = isTenantContextMissing
? t(
"msg.dev.request.tenant_required_detail",
"현재 계정은 테넌트와 연결되어 있지 않아 개발자 권한을 신청할 수 없습니다.",
)
: t(
"msg.dev.dashboard.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
);
return (
<DeveloperAccessRequestCard
title={t("ui.common.overview.title", "운영 현황")}
@@ -1298,8 +1279,14 @@ function GlobalOverviewPage() {
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedMessage={deniedMessage}
deniedDetailMessage={deniedDetailMessage}
deniedMessage={t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.dashboard.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
onAction={() => navigate("/developer-requests")}
/>