1
0
forked from baron/baron-sso

devfront 테넌트 미소속 개발자 신청 안내 추가

This commit is contained in:
2026-06-08 13:52:40 +09:00
parent 894feb20f1
commit 41e755b1c7
11 changed files with 228 additions and 22 deletions

View File

@@ -174,6 +174,22 @@ describe("AuditLogsPage", () => {
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
it("shows a tenant-required notice when tenant context is missing", async () => {
gateState = {
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
isLoadingDeveloperAccessGate: false,
isTenantContextMissing: true,
};
const container = await renderPage();
expect(container.textContent).toContain(
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
);
expect(container.textContent).not.toContain("개발자 권한 신청");
});
it("exports the fetched logs as CSV", async () => {
const createObjectURL = vi
.spyOn(URL, "createObjectURL")

View File

@@ -97,6 +97,7 @@ function AuditLogsPage() {
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
isTenantContextMissing,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole,
@@ -142,6 +143,24 @@ 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")}
@@ -151,18 +170,12 @@ function AuditLogsPage() {
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.audit.access_denied",
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
)}
deniedMessage={deniedMessage}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.audit.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
deniedDetailMessage={deniedDetailMessage}
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
onAction={() => navigate("/developer-requests")}
/>

View File

@@ -277,4 +277,31 @@ describe("ClientsPage", () => {
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
it("shows a tenant-required notice 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({
role: "user",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
});
const container = await renderPage();
expect(container.textContent).toContain(
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
);
expect(fetchDeveloperRequestStatusMock).not.toHaveBeenCalled();
});
});

View File

@@ -67,6 +67,7 @@ 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,
@@ -93,7 +94,8 @@ function ClientsPage() {
} = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled: hasAccessToken && profileRole === "user",
enabled:
hasAccessToken && profileRole === "user" && !isTenantContextMissing,
});
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
@@ -108,7 +110,9 @@ function ClientsPage() {
const canCreateClient = createAccessState === "can_create";
const isDeveloperRequestPending = createAccessState === "pending";
const canRequestDeveloperAccess =
createAccessState === "request_required" && !isLoadingRequest;
createAccessState === "request_required" &&
!isLoadingRequest &&
!isTenantContextMissing;
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
@@ -229,7 +233,20 @@ function ClientsPage() {
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
)}
actions={
canCreateClient ? (
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 ? (
<Button
size="sm"
className="mt-1 shadow-lg shadow-primary/30"
@@ -453,6 +470,11 @@ function ClientsPage() {
"msg.dev.clients.empty_filtered",
"조건에 맞는 연동 앱이 없습니다.",
)
: isTenantContextMissing
? t(
"msg.dev.clients.empty_tenant_missing",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create",
@@ -475,6 +497,11 @@ 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",
@@ -499,6 +526,18 @@ 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"
@@ -672,6 +711,7 @@ function RequestAccessModal({
const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState("");
const isTenantContextMissing = !tenantId.trim();
useEffect(() => {
if (!isOpen) return;
@@ -688,6 +728,15 @@ 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

@@ -9,12 +9,16 @@ export type DeveloperAccessGateState = {
isDeveloperRequestPending: boolean;
canRequestDeveloperAccess: boolean;
isLoadingDeveloperAccessGate: boolean;
isTenantContextMissing: boolean;
};
export function resolveDeveloperAccessGate(
profileRole: string,
requestStatus?: DeveloperRequestStatus,
): Omit<DeveloperAccessGateState, "isLoadingDeveloperAccessGate"> {
): Omit<
DeveloperAccessGateState,
"isLoadingDeveloperAccessGate" | "isTenantContextMissing"
> {
const hasDeveloperAccess =
profileRole === "super_admin" || requestStatus === "approved";
const isDeveloperRequestPending = requestStatus === "pending";
@@ -55,10 +59,12 @@ export function useDeveloperAccessGate({
}) {
const shouldFetchRequestStatus =
shouldFetchDeveloperRequestStatus(profileRole);
const isTenantContextMissing = !tenantId?.trim();
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled: hasAccessToken && shouldFetchRequestStatus,
enabled:
hasAccessToken && shouldFetchRequestStatus && !isTenantContextMissing,
});
const resolvedGate = resolveDeveloperAccessGate(
@@ -68,6 +74,9 @@ export function useDeveloperAccessGate({
return {
...resolvedGate,
isTenantContextMissing,
canRequestDeveloperAccess:
resolvedGate.canRequestDeveloperAccess && !isTenantContextMissing,
isLoadingDeveloperAccessGate: shouldShowDeveloperAccessLoading(
profileRole,
isLoadingIdentity,

View File

@@ -185,4 +185,33 @@ describe("DeveloperRequestPage", () => {
tenantId: "tenant-1",
});
});
it("shows a tenant-required notice and hides the request button when tenant 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",
});
const container = await renderPage();
expect(container.textContent).toContain(
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
);
expect(container.textContent).not.toContain("신규 신청하기");
expect(requestDeveloperAccessMock).not.toHaveBeenCalled();
});
});

View File

@@ -56,6 +56,7 @@ 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>>({});
@@ -179,7 +180,7 @@ export default function DeveloperRequestPage() {
)
}
actions={
!isSuperAdmin && !hasActiveRequest ? (
!isSuperAdmin && !isTenantContextMissing && !hasActiveRequest ? (
<Button onClick={() => setIsRequestModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
@@ -188,6 +189,28 @@ 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">
@@ -463,6 +486,15 @@ function RequestAccessModal({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!tenantId.trim()) {
alert(
t(
"msg.dev.request.tenant_required",
"개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다.",
),
);
return;
}
mutation.mutate({
name,
organization,

View File

@@ -968,6 +968,7 @@ function GlobalOverviewPage() {
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
isTenantContextMissing,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole,
@@ -1266,6 +1267,24 @@ 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", "운영 현황")}
@@ -1275,18 +1294,12 @@ function GlobalOverviewPage() {
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.dashboard.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
deniedMessage={deniedMessage}
deniedDetailMessage={deniedDetailMessage}
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
onAction={() => navigate("/developer-requests")}
/>

View File

@@ -329,6 +329,8 @@ user_desc = "Review your request history and submit a new access request."
[msg.dev.request.modal]
desc = "Please enter the reason for your request. It will be approved after administrator review."
tenant_required = "You need to be assigned to a tenant before you can request developer access."
tenant_required_detail = "This account is not linked to a tenant yet, so developer access cannot be requested."
[msg.dev.clients]
load_error = "Error loading clients: {{error}}"
@@ -342,12 +344,16 @@ empty_detail = "RPs will appear here when a relationship is assigned to your acc
empty_can_create = "No linked apps have been registered yet."
empty_can_create_detail = "Create a new RP with the Add linked app button, and it will appear here."
create_requires_request = "You do not have permission to create applications.\nSubmit a developer access request and wait for approval."
create_requires_tenant = "You need to be assigned to a tenant before you can request developer access."
create_requires_tenant_detail = "This account is not linked to a tenant yet, so developer access cannot be requested."
create_pending_detail = "Your developer access request is under review. You will be able to add applications after approval."
create_forbidden_detail = "You do not have permission to create applications. Ask an administrator to grant developer access or the appropriate RP permissions."
empty_filtered = "No linked apps match the current filters."
empty_filtered_detail = "Try changing the search text or filters."
empty_pending = "Your developer access request is under review."
empty_pending_detail = "You can add linked apps after a super admin approves it."
empty_tenant_missing = "You need to be assigned to a tenant before you can request developer access."
empty_tenant_missing_detail = "This account is not linked to a tenant yet, so developer access cannot be requested."
[msg.dev.clients.consents]
empty = "No consents found."

View File

@@ -329,6 +329,8 @@ user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수
[msg.dev.request.modal]
desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다."
tenant_required = "개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다."
tenant_required_detail = "현재 계정은 테넌트와 연결되어 있지 않아 개발자 권한을 신청할 수 없습니다."
[msg.dev.clients]
deleted = "앱이 삭제되었습니다."
@@ -339,12 +341,16 @@ empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩
empty_can_create = "아직 등록된 연동 앱이 없습니다."
empty_can_create_detail = "연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다."
create_requires_request = "연동 앱을 생성할 권한이 없습니다.\n개발자 권한 신청을 요청한 뒤 승인 받아주세요."
create_requires_tenant = "개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다."
create_requires_tenant_detail = "현재 계정은 테넌트와 연결되어 있지 않아 개발자 권한을 신청할 수 없습니다."
create_pending_detail = "개발자 권한 신청을 검토 중입니다. 승인되면 연동 앱을 추가할 수 있습니다."
create_forbidden_detail = "연동 앱을 생성할 권한이 없습니다. 관리자에게 개발자 권한 또는 적절한 RP 권한 부여를 요청해 주세요."
empty_filtered = "조건에 맞는 연동 앱이 없습니다."
empty_filtered_detail = "검색어나 필터 조건을 변경해 보세요."
empty_pending = "개발자 권한 신청을 검토 중입니다."
empty_pending_detail = "super admin이 승인하면 연동 앱을 추가할 수 있습니다."
empty_tenant_missing = "개발자 권한을 신청하려면 먼저 테넌트에 소속되어 있어야 합니다."
empty_tenant_missing_detail = "현재 계정은 테넌트와 연결되어 있지 않아 개발자 권한을 신청할 수 없습니다."
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
loading = "앱 정보를 불러오는 중..."
showing = "총 {{shown}}개의 애플리케이션이 등록되어 있습니다."

View File

@@ -343,6 +343,8 @@ user_desc = ""
[msg.dev.request.modal]
desc = ""
tenant_required = ""
tenant_required_detail = ""
[msg.dev.request.status]
approved = ""
@@ -379,6 +381,8 @@ empty = ""
empty_detail = ""
empty_can_create = ""
empty_can_create_detail = ""
create_requires_tenant = ""
create_requires_tenant_detail = ""
create_requires_request = ""
create_pending_detail = ""
create_forbidden_detail = ""
@@ -386,6 +390,8 @@ empty_filtered = ""
empty_filtered_detail = ""
empty_pending = ""
empty_pending_detail = ""
empty_tenant_missing = ""
empty_tenant_missing_detail = ""
[msg.dev.clients.consents]
empty = ""