forked from baron/baron-sso
역할별 403 권한 안내 문구 일관화
This commit is contained in:
51
devfront/src/components/common/ForbiddenMessage.tsx
Normal file
51
devfront/src/components/common/ForbiddenMessage.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { t } from "../../lib/i18n";
|
||||||
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
resourceToken: "audit" | "clients";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForbiddenMessage({ resourceToken }: Props) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const rawProfile = auth.user?.profile as Record<string, unknown> | undefined;
|
||||||
|
const role = resolveProfileRole(rawProfile);
|
||||||
|
|
||||||
|
let explanation = t(
|
||||||
|
"msg.dev.forbidden.default",
|
||||||
|
"해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (role === "rp_admin") {
|
||||||
|
explanation = t(
|
||||||
|
"msg.dev.forbidden.rp_admin",
|
||||||
|
"RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.",
|
||||||
|
);
|
||||||
|
} else if (role === "tenant_admin") {
|
||||||
|
explanation = t(
|
||||||
|
"msg.dev.forbidden.tenant_admin",
|
||||||
|
"테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.",
|
||||||
|
);
|
||||||
|
} else if (role === "user" || role === "tenant_member") {
|
||||||
|
explanation = t(
|
||||||
|
"msg.dev.forbidden.user",
|
||||||
|
"일반 사용자는 관리자 화면에 접근할 수 없습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", {
|
||||||
|
resource:
|
||||||
|
resourceToken === "audit"
|
||||||
|
? t("ui.dev.audit.title", "Audit Logs")
|
||||||
|
: t("ui.dev.clients.registry.subtitle", "연동 앱"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-12 text-center text-red-500/90 gap-3">
|
||||||
|
<ShieldAlert className="h-10 w-10 text-red-500/80 mb-2" />
|
||||||
|
<h3 className="text-xl font-bold text-foreground">{title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">{explanation}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -170,14 +171,7 @@ function AuditLogsPage() {
|
|||||||
if (query.error) {
|
if (query.error) {
|
||||||
const axiosError = query.error as AxiosError<{ error?: string }>;
|
const axiosError = query.error as AxiosError<{ error?: string }>;
|
||||||
if (axiosError.response?.status === 403) {
|
if (axiosError.response?.status === 403) {
|
||||||
return (
|
return <ForbiddenMessage resourceToken="audit" />;
|
||||||
<div className="p-8 text-center text-red-500">
|
|
||||||
{t(
|
|
||||||
"msg.dev.audit.forbidden",
|
|
||||||
"감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요.",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const errMsg =
|
const errMsg =
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "../../components/ui/avatar";
|
} from "../../components/ui/avatar";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -133,9 +134,12 @@ function ClientsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (clientError) {
|
if (clientError) {
|
||||||
|
const axiosError = clientError as AxiosError<{ error?: string }>;
|
||||||
|
if (axiosError.response?.status === 403) {
|
||||||
|
return <ForbiddenMessage resourceToken="clients" />;
|
||||||
|
}
|
||||||
const errMsg =
|
const errMsg =
|
||||||
(clientError as AxiosError<{ error?: string }>).response?.data?.error ??
|
axiosError.response?.data?.error ?? (clientError as Error).message;
|
||||||
(clientError as Error).message;
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-red-500">
|
||||||
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
||||||
|
|||||||
@@ -57,4 +57,60 @@ test.describe("DevFront security and isolation", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/clients$/);
|
await expect(page).toHaveURL(/\/clients$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedAuth(page, "rp_admin");
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
clients: [] as ReturnType<typeof makeClient>[],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.route("**/api/v1/dev/clients", async (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: '{"error": "forbidden"}',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/clients");
|
||||||
|
await expect(page.getByText(/RP 관리자는|RP administrators can only access/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tenant_admin receives 403 on audit logs and sees ForbiddenMessage", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedAuth(page, "tenant_admin");
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
clients: [] as ReturnType<typeof makeClient>[],
|
||||||
|
consents: [] as Consent[],
|
||||||
|
auditLogsByCursor: undefined,
|
||||||
|
};
|
||||||
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
|
await page.route("**/api/v1/dev/audit-logs*", async (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: '{"error": "forbidden"}',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/audit-logs");
|
||||||
|
await expect(
|
||||||
|
page.getByText(/테넌트 관리자 권한|Tenant administrator permissions/i),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -326,6 +326,13 @@ logout_confirm = "Are you sure you want to log out?"
|
|||||||
access_denied_description = "DevFront is for administrators only. Request access from your administrator."
|
access_denied_description = "DevFront is for administrators only. Request access from your administrator."
|
||||||
access_denied_title = "Access denied."
|
access_denied_title = "Access denied."
|
||||||
|
|
||||||
|
[msg.dev.forbidden]
|
||||||
|
default = "You do not have permission to access this resource. Please contact an administrator."
|
||||||
|
rp_admin = "RP administrators can only access resources for the apps they manage."
|
||||||
|
tenant_admin = "Tenant administrator permissions are not configured correctly or have expired."
|
||||||
|
user = "Regular users cannot access the developer console."
|
||||||
|
title = "Access Denied: {{resource}}"
|
||||||
|
|
||||||
[msg.dev.audit]
|
[msg.dev.audit]
|
||||||
empty = "No audit logs found."
|
empty = "No audit logs found."
|
||||||
forbidden = "You do not have permission to view audit logs. Please request access from an administrator."
|
forbidden = "You do not have permission to view audit logs. Please request access from an administrator."
|
||||||
|
|||||||
@@ -326,6 +326,13 @@ logout_confirm = "로그아웃 하시겠습니까?"
|
|||||||
access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요."
|
access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요."
|
||||||
access_denied_title = "접근 권한이 없습니다."
|
access_denied_title = "접근 권한이 없습니다."
|
||||||
|
|
||||||
|
[msg.dev.forbidden]
|
||||||
|
default = "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요."
|
||||||
|
rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다."
|
||||||
|
tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다."
|
||||||
|
user = "일반 사용자는 관리자 화면에 접근할 수 없습니다."
|
||||||
|
title = "{{resource}} 접근 권한 없음"
|
||||||
|
|
||||||
[msg.dev.audit]
|
[msg.dev.audit]
|
||||||
empty = "조회된 감사 로그가 없습니다."
|
empty = "조회된 감사 로그가 없습니다."
|
||||||
forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요."
|
forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요."
|
||||||
|
|||||||
@@ -326,6 +326,13 @@ logout_confirm = ""
|
|||||||
access_denied_description = ""
|
access_denied_description = ""
|
||||||
access_denied_title = ""
|
access_denied_title = ""
|
||||||
|
|
||||||
|
[msg.dev.forbidden]
|
||||||
|
default = ""
|
||||||
|
rp_admin = ""
|
||||||
|
tenant_admin = ""
|
||||||
|
user = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[msg.dev.audit]
|
[msg.dev.audit]
|
||||||
empty = ""
|
empty = ""
|
||||||
forbidden = ""
|
forbidden = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user