forked from baron/baron-sso
adminfront 및 백엔드: ReBAC 기반 각 탭별 읽기/쓰기 권한 제어 구현
This commit is contained in:
@@ -93,6 +93,13 @@ vi.mock("react-oidc-context", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => users[0]),
|
||||
fetchTenant: vi.fn(async (tenantId) => ({
|
||||
id: tenantId,
|
||||
name: "Test Tenant",
|
||||
slug: "test-tenant",
|
||||
userPermissions: { view: true, manage: true, manage_admins: true },
|
||||
})),
|
||||
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
||||
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||
addTenantOwner: vi.fn(async () => undefined),
|
||||
|
||||
@@ -29,6 +29,7 @@ type DomainTagInputProps = {
|
||||
confirmedConflicts?: string[];
|
||||
onConfirmedConflictsChange?: (domains: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function DomainTagInput({
|
||||
@@ -40,6 +41,7 @@ export function DomainTagInput({
|
||||
confirmedConflicts = [],
|
||||
onConfirmedConflictsChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: DomainTagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
||||
@@ -107,14 +109,16 @@ export function DomainTagInput({
|
||||
className="gap-1 rounded-md"
|
||||
>
|
||||
<span>{domain}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||
onClick={() => removeDomain(domain)}
|
||||
aria-label={t("ui.common.remove", "삭제")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||
onClick={() => removeDomain(domain)}
|
||||
aria-label={t("ui.common.remove", "삭제")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
@@ -133,6 +137,7 @@ export function DomainTagInput({
|
||||
tokenizeInput();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
||||
placeholder={value.length === 0 ? placeholder : undefined}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,7 @@ type ParentTenantSelectorProps = {
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
compact?: boolean;
|
||||
controlTestId?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ParentTenantSelector({
|
||||
@@ -53,6 +54,7 @@ export function ParentTenantSelector({
|
||||
localTenantFilter,
|
||||
compact = false,
|
||||
controlTestId,
|
||||
disabled = false,
|
||||
}: ParentTenantSelectorProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||
@@ -112,6 +114,7 @@ export function ParentTenantSelector({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
@@ -141,7 +144,7 @@ export function ParentTenantSelector({
|
||||
{localPickerLabel && (
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled}>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
@@ -228,6 +231,7 @@ export function ParentTenantSelector({
|
||||
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
||||
onClick={() => onChange("")}
|
||||
aria-label={noneLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type React from "react";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
interface TenantPermissionGuardProps {
|
||||
tenantId: string;
|
||||
relation: "view" | "manage" | "manage_admins";
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TenantPermissionGuard({
|
||||
tenantId,
|
||||
relation,
|
||||
fallback = null,
|
||||
children,
|
||||
}: TenantPermissionGuardProps) {
|
||||
const { hasPermission, isLoading } = useTenantPermission(tenantId);
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!hasPermission(relation)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
|
||||
import { useTenantPermission } from "./useTenantPermission";
|
||||
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
|
||||
|
||||
vi.mock("../../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(),
|
||||
fetchTenant: vi.fn(),
|
||||
}));
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useTenantPermission", () => {
|
||||
it("returns true for all permissions if user is super_admin", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: "user-super",
|
||||
role: "super_admin",
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue({
|
||||
id: "tenant-1",
|
||||
name: "Super Tenant",
|
||||
userPermissions: { view: false, manage: false, manage_admins: false },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasPermission("view")).toBe(true);
|
||||
expect(result.current.hasPermission("manage")).toBe(true);
|
||||
expect(result.current.hasPermission("manage_admins")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue({
|
||||
id: "tenant-2",
|
||||
name: "Tenant Admin Corp",
|
||||
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasPermission("view")).toBe(true);
|
||||
expect(result.current.hasPermission("manage")).toBe(true);
|
||||
expect(result.current.hasPermission("manage_admins")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TenantPermissionGuard", () => {
|
||||
it("renders children when user has permission", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue({
|
||||
id: "tenant-3",
|
||||
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<TenantPermissionGuard tenantId="tenant-3" relation="manage" fallback={<div>Access Denied</div>}>
|
||||
<div>Access Granted</div>
|
||||
</TenantPermissionGuard>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Access Granted")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Access Denied")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback when user lacks permission", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue({
|
||||
id: "tenant-4",
|
||||
userPermissions: { view: true, manage: false, manage_admins: false },
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<TenantPermissionGuard tenantId="tenant-4" relation="manage" fallback={<div>Access Denied</div>}>
|
||||
<div>Access Granted</div>
|
||||
</TenantPermissionGuard>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Access Denied")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Access Granted")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
26
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
26
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export function useTenantPermission(tenantId: string) {
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const hasPermission = (requiredRelation: "view" | "manage" | "manage_admins"): boolean => {
|
||||
// Super Admin always has full bypass access
|
||||
if (normalizeAdminRole(profile?.role) === "super_admin") {
|
||||
return true;
|
||||
}
|
||||
return !!tenant?.userPermissions?.[requiredRelation];
|
||||
};
|
||||
|
||||
return { hasPermission, isLoading: !tenant };
|
||||
}
|
||||
@@ -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", "관리자 추가")}
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -33,6 +33,11 @@ export type TenantSummary = {
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number; // 해당 테넌트 직접 소속 인원
|
||||
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
|
||||
userPermissions?: {
|
||||
view: boolean;
|
||||
manage: boolean;
|
||||
manage_admins: boolean;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user