1
0
forked from baron/baron-sso

RP 관계 범위의 콘솔 접근 허용

This commit is contained in:
2026-04-20 10:46:17 +09:00
parent 0b8eaec636
commit 51e46a4d00
10 changed files with 376 additions and 109 deletions

View File

@@ -270,11 +270,6 @@ function AppLayout() {
);
const displayRoleKey = profile?.role || currentRole;
const isDevConsoleAllowed = [
"super_admin",
"tenant_admin",
"rp_admin",
].includes(currentRole);
const expiresAtSec = auth.user?.expires_at;
const remainingMs =
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
@@ -360,24 +355,23 @@ function AppLayout() {
</span>
</div>
<div className="flex flex-col gap-1">
{isDevConsoleAllowed &&
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
</div>
</nav>
</div>

View File

@@ -1,7 +1,5 @@
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet } from "react-router-dom";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
export default function AuthGuard() {
const auth = useAuth();
@@ -18,39 +16,5 @@ export default function AuthGuard() {
return <Navigate to="/login" replace />;
}
const normalizedRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const isTenantMember =
normalizedRole === "user" || normalizedRole === "tenant_member";
if (isTenantMember) {
return (
<div className="min-h-screen grid place-items-center bg-background text-foreground p-6">
<div className="max-w-lg w-full rounded-xl border border-border bg-card p-6 space-y-4">
<h1 className="text-xl font-semibold">
{t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")}
</h1>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.auth.access_denied_description",
"DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요.",
)}
</p>
<button
type="button"
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
onClick={() => {
auth.removeUser();
window.location.href = "/login";
}}
>
{t("ui.common.back_to_login", "로그인으로 돌아가기")}
</button>
</div>
</div>
);
}
return <Outlet />;
}

View File

@@ -38,12 +38,17 @@ import {
} from "../../components/ui/table";
import { fetchClients, fetchDevStats } from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils";
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const role = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const canCreateClient = role !== "user" && role !== "tenant_member";
const {
data,
@@ -168,16 +173,18 @@ function ClientsPage() {
)}
</CardDescription>
</div>
<div className="hidden items-center gap-2 md:flex">
<Button
size="sm"
className="shadow-lg shadow-primary/30"
onClick={() => navigate("/clients/new")}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
{canCreateClient && (
<div className="hidden items-center gap-2 md:flex">
<Button
size="sm"
className="shadow-lg shadow-primary/30"
onClick={() => navigate("/clients/new")}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
)}
</div>
<div className="mt-4 flex flex-col gap-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
@@ -217,7 +224,7 @@ function ClientsPage() {
)}
</Badge>
<Badge variant="success">
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
{t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
</Badge>
</div>
</div>
@@ -319,12 +326,14 @@ function ClientsPage() {
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
<div className="flex items-center gap-2 md:hidden">
<Button size="sm" onClick={() => navigate("/clients/new")}>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
{canCreateClient && (
<div className="flex items-center gap-2 md:hidden">
<Button size="sm" onClick={() => navigate("/clients/new")}>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
@@ -350,6 +359,29 @@ function ClientsPage() {
</TableRow>
</TableHeader>
<TableBody>
{filteredClients.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="h-32 text-center text-muted-foreground"
>
<div className="space-y-1">
<p className="font-medium text-foreground">
{t(
"msg.dev.clients.empty",
"조회 가능한 RP가 없습니다.",
)}
</p>
<p className="text-sm">
{t(
"msg.dev.clients.empty_detail",
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
)}
{filteredClients.map((client) => (
<TableRow key={client.id} className="bg-card/40">
<TableCell>

View File

@@ -17,7 +17,7 @@ test.describe("DevFront role report", () => {
});
});
test("user(tenant_member) is blocked with 안내 문구", async ({
test("user(tenant_member) can enter and sees empty RP list", async ({
page,
}, testInfo) => {
await seedAuth(page, "user");
@@ -29,9 +29,12 @@ test.describe("DevFront role report", () => {
await page.goto("/clients");
await expect(
page.getByText(/관리자 전용 화면|administrator only/i),
page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i),
).toBeVisible();
await captureEvidence(page, testInfo, "role-user-blocked");
await expect(
page.getByText(/연동 앱|Connected Application/i),
).toBeVisible();
await captureEvidence(page, testInfo, "role-user-empty-rps");
});
test("rp_admin sees only assigned Gitea app and its logs", async ({

View File

@@ -59,14 +59,25 @@ test.describe("DevFront security and isolation", () => {
await expect(page.getByText("Server side App")).not.toBeVisible();
});
test("tenant_member user is blocked at AuthGuard", async ({ page }) => {
test("tenant_member user can enter DevFront and sees empty RP list", async ({
page,
}) => {
await seedAuth(page, "tenant_member");
const state = {
clients: [] as ReturnType<typeof makeClient>[],
consents: [] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("/clients");
await expect(
page.getByText(/DevFront는 관리자 전용 화면입니다|administrator access/i),
).toBeVisible();
await expect(page).toHaveURL(/\/clients$/);
await expect(
page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i),
).toBeVisible();
await expect(
page.getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i }),
).not.toBeVisible();
});
test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({