1
0
forked from baron/baron-sso

adminfront 및 백엔드: ReBAC 기반 각 탭별 읽기/쓰기 권한 제어 구현

This commit is contained in:
2026-06-10 10:01:30 +09:00
parent c880b3c333
commit 85707500ef
13 changed files with 485 additions and 40 deletions

View File

@@ -11,6 +11,7 @@ import {
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -69,6 +70,8 @@ export function TenantAdminsAndOwnersTab() {
const _currentUserId = auth.user?.profile.sub;
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage_admins");
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
@@ -382,6 +385,7 @@ export function TenantAdminsAndOwnersTab() {
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
@@ -471,6 +475,7 @@ export function TenantAdminsAndOwnersTab() {
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}

View File

@@ -5,6 +5,7 @@ import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { useTenantPermission } from "../hooks/useTenantPermission";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
@@ -17,13 +18,7 @@ function TenantDetailPage() {
enabled: tenantId.length > 0,
});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = profileRole === "super_admin";
const { hasPermission } = useTenantPermission(tenantId);
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
@@ -110,7 +105,7 @@ function TenantDetailPage() {
>
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link>
{canAccessSchema && (
{hasPermission("view") && (
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${

View File

@@ -21,6 +21,7 @@ import {
import type React from "react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -126,6 +127,7 @@ interface UserGroupTreeNodeProps {
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
isWritable?: boolean;
}
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
@@ -137,6 +139,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
onAddSubGroup,
addMemberMutation,
removeMemberMutation,
isWritable = true,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children.length > 0;
@@ -200,6 +203,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
e.stopPropagation();
onAddSubGroup(node.id);
}}
disabled={!isWritable}
>
<Plus size={14} />
</Button>
@@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
e.stopPropagation();
onDelete(node.id);
}}
disabled={!isWritable}
>
<Trash2 size={14} className="text-destructive" />
</Button>
@@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
onAddSubGroup={onAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
isWritable={isWritable}
/>
))}
</>
@@ -240,6 +246,9 @@ function TenantGroupsPage() {
const tenantId = params.tenantId ?? "";
const _queryClient = useQueryClient();
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage");
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
@@ -423,6 +432,7 @@ function TenantGroupsPage() {
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
@@ -437,6 +447,7 @@ function TenantGroupsPage() {
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀",
@@ -449,9 +460,10 @@ function TenantGroupsPage() {
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
disabled={!isWritable}
>
<option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => (
@@ -469,6 +481,7 @@ function TenantGroupsPage() {
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
@@ -478,7 +491,7 @@ function TenantGroupsPage() {
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
disabled={!newGroupName || createMutation.isPending || !isWritable}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
@@ -569,6 +582,7 @@ function TenantGroupsPage() {
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
isWritable={isWritable}
/>
))}
</TableBody>

View File

@@ -25,6 +25,7 @@ import {
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import { useTenantPermission } from "../hooks/useTenantPermission";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
@@ -52,6 +53,9 @@ export function TenantProfilePage() {
enabled: tenantId.length > 0,
});
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage");
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
@@ -261,13 +265,13 @@ export function TenantProfilePage() {
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input value={name} onChange={(e) => setName(e.target.value)} disabled={!isWritable} />
</div>
<div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
<Input value={slug} onChange={(e) => setSlug(e.target.value)} disabled={!isWritable} />
</div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector
@@ -283,6 +287,7 @@ export function TenantProfilePage() {
excludeTenantId={tenantId}
compact
controlTestId="tenant-parent-picker-control"
disabled={!isWritable}
/>
</div>
</div>
@@ -300,6 +305,7 @@ export function TenantProfilePage() {
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
disabled={!isWritable}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
@@ -346,9 +352,10 @@ export function TenantProfilePage() {
id="tenant-org-unit-type"
name="tenant-org-unit-type"
data-testid="tenant-org-unit-type-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
disabled={!isWritable}
>
<option value="">{t("ui.common.none", "없음")}</option>
{orgUnitTypeOptions.map((option) => (
@@ -365,13 +372,14 @@ export function TenantProfilePage() {
<select
id="tenant-visibility"
name="tenant-visibility"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={tenantVisibility}
onChange={(event) =>
setTenantVisibility(
event.target.value as TenantVisibility,
)
}
disabled={!isWritable}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
@@ -392,11 +400,12 @@ export function TenantProfilePage() {
</Label>
<select
id="worksmobileExcluded"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={worksmobileExcluded ? "excluded" : "enabled"}
onChange={(event) =>
setWorksmobileExcluded(event.target.value === "excluded")
}
disabled={!isWritable}
>
<option value="enabled">
{t(
@@ -424,6 +433,7 @@ export function TenantProfilePage() {
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={!isWritable}
/>
</div>
<div className="space-y-1">
@@ -442,6 +452,7 @@ export function TenantProfilePage() {
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
disabled={!isWritable}
/>
</div>
<div className="space-y-1">
@@ -454,6 +465,7 @@ export function TenantProfilePage() {
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
disabled={!isWritable}
>
{t("ui.common.status.active", "활성")}
</Button>
@@ -462,6 +474,7 @@ export function TenantProfilePage() {
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
disabled={!isWritable}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
@@ -480,7 +493,7 @@ export function TenantProfilePage() {
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending || isProtectedSeedTenant}
disabled={deleteMutation.isPending || isProtectedSeedTenant || !isWritable}
title={
isProtectedSeedTenant
? t(
@@ -499,7 +512,7 @@ export function TenantProfilePage() {
variant="default"
className="bg-green-600 hover:bg-green-700"
onClick={handleApprove}
disabled={approveMutation.isPending}
disabled={approveMutation.isPending || !isWritable}
>
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
</Button>
@@ -512,7 +525,8 @@ export function TenantProfilePage() {
disabled={
updateMutation.isPending ||
tenantQuery.isLoading ||
name.trim() === ""
name.trim() === "" ||
!isWritable
}
>
<Save size={16} />