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", () => ({
|
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]]),
|
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
||||||
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||||
addTenantOwner: vi.fn(async () => undefined),
|
addTenantOwner: vi.fn(async () => undefined),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type DomainTagInputProps = {
|
|||||||
confirmedConflicts?: string[];
|
confirmedConflicts?: string[];
|
||||||
onConfirmedConflictsChange?: (domains: string[]) => void;
|
onConfirmedConflictsChange?: (domains: string[]) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DomainTagInput({
|
export function DomainTagInput({
|
||||||
@@ -40,6 +41,7 @@ export function DomainTagInput({
|
|||||||
confirmedConflicts = [],
|
confirmedConflicts = [],
|
||||||
onConfirmedConflictsChange,
|
onConfirmedConflictsChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
}: DomainTagInputProps) {
|
}: DomainTagInputProps) {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
||||||
@@ -107,14 +109,16 @@ export function DomainTagInput({
|
|||||||
className="gap-1 rounded-md"
|
className="gap-1 rounded-md"
|
||||||
>
|
>
|
||||||
<span>{domain}</span>
|
<span>{domain}</span>
|
||||||
<button
|
{!disabled && (
|
||||||
type="button"
|
<button
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
type="button"
|
||||||
onClick={() => removeDomain(domain)}
|
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||||
aria-label={t("ui.common.remove", "삭제")}
|
onClick={() => removeDomain(domain)}
|
||||||
>
|
aria-label={t("ui.common.remove", "삭제")}
|
||||||
<X size={12} />
|
>
|
||||||
</button>
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
<Input
|
<Input
|
||||||
@@ -133,6 +137,7 @@ export function DomainTagInput({
|
|||||||
tokenizeInput();
|
tokenizeInput();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={disabled}
|
||||||
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
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}
|
placeholder={value.length === 0 ? placeholder : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type ParentTenantSelectorProps = {
|
|||||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
controlTestId?: string;
|
controlTestId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ParentTenantSelector({
|
export function ParentTenantSelector({
|
||||||
@@ -53,6 +54,7 @@ export function ParentTenantSelector({
|
|||||||
localTenantFilter,
|
localTenantFilter,
|
||||||
compact = false,
|
compact = false,
|
||||||
controlTestId,
|
controlTestId,
|
||||||
|
disabled = false,
|
||||||
}: ParentTenantSelectorProps) {
|
}: ParentTenantSelectorProps) {
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||||
@@ -112,6 +114,7 @@ export function ParentTenantSelector({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Building2 className="h-4 w-4" />
|
<Building2 className="h-4 w-4" />
|
||||||
{orgChartPickerLabel ??
|
{orgChartPickerLabel ??
|
||||||
@@ -141,7 +144,7 @@ export function ParentTenantSelector({
|
|||||||
{localPickerLabel && (
|
{localPickerLabel && (
|
||||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button type="button" variant="outline" size="sm">
|
<Button type="button" variant="outline" size="sm" disabled={disabled}>
|
||||||
<Building2 className="h-4 w-4" />
|
<Building2 className="h-4 w-4" />
|
||||||
{localPickerLabel}
|
{localPickerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -228,6 +231,7 @@ export function ParentTenantSelector({
|
|||||||
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
||||||
onClick={() => onChange("")}
|
onClick={() => onChange("")}
|
||||||
aria-label={noneLabel}
|
aria-label={noneLabel}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</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 { useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -69,6 +70,8 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
const _currentUserId = auth.user?.profile.sub;
|
const _currentUserId = auth.user?.profile.sub;
|
||||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||||
const tenantId = tenantIdParam ?? "";
|
const tenantId = tenantIdParam ?? "";
|
||||||
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
|
const isWritable = hasPermission("manage_admins");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||||
@@ -382,6 +385,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<Button
|
<Button
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
onClick={() => setDialogMode("owner")}
|
onClick={() => setDialogMode("owner")}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||||
@@ -471,6 +475,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
<Button
|
<Button
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
onClick={() => setDialogMode("admin")}
|
onClick={() => setDialogMode("admin")}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from "../../../components/ui/button";
|
|||||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { normalizeAdminRole } from "../../../lib/roles";
|
import { normalizeAdminRole } from "../../../lib/roles";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
|
|
||||||
function TenantDetailPage() {
|
function TenantDetailPage() {
|
||||||
const params = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
@@ -17,13 +18,7 @@ function TenantDetailPage() {
|
|||||||
enabled: tenantId.length > 0,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
queryKey: ["me"],
|
|
||||||
queryFn: fetchMe,
|
|
||||||
});
|
|
||||||
|
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
|
||||||
const canAccessSchema = profileRole === "super_admin";
|
|
||||||
|
|
||||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||||
const isOrganizationTab = location.pathname.includes("/organization");
|
const isOrganizationTab = location.pathname.includes("/organization");
|
||||||
@@ -110,7 +105,7 @@ function TenantDetailPage() {
|
|||||||
>
|
>
|
||||||
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
||||||
</Link>
|
</Link>
|
||||||
{canAccessSchema && (
|
{hasPermission("view") && (
|
||||||
<Link
|
<Link
|
||||||
to={`/tenants/${tenantId}/schema`}
|
to={`/tenants/${tenantId}/schema`}
|
||||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -126,6 +127,7 @@ interface UserGroupTreeNodeProps {
|
|||||||
AxiosError<{ error?: string }>,
|
AxiosError<{ error?: string }>,
|
||||||
{ groupId: string; userId: string }
|
{ groupId: string; userId: string }
|
||||||
>;
|
>;
|
||||||
|
isWritable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||||
@@ -137,6 +139,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
onAddSubGroup,
|
onAddSubGroup,
|
||||||
addMemberMutation,
|
addMemberMutation,
|
||||||
removeMemberMutation,
|
removeMemberMutation,
|
||||||
|
isWritable = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
@@ -200,6 +203,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAddSubGroup(node.id);
|
onAddSubGroup(node.id);
|
||||||
}}
|
}}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(node.id);
|
onDelete(node.id);
|
||||||
}}
|
}}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} className="text-destructive" />
|
<Trash2 size={14} className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
onAddSubGroup={onAddSubGroup}
|
onAddSubGroup={onAddSubGroup}
|
||||||
addMemberMutation={addMemberMutation}
|
addMemberMutation={addMemberMutation}
|
||||||
removeMemberMutation={removeMemberMutation}
|
removeMemberMutation={removeMemberMutation}
|
||||||
|
isWritable={isWritable}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -240,6 +246,9 @@ function TenantGroupsPage() {
|
|||||||
const tenantId = params.tenantId ?? "";
|
const tenantId = params.tenantId ?? "";
|
||||||
const _queryClient = useQueryClient();
|
const _queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
|
const isWritable = hasPermission("manage");
|
||||||
|
|
||||||
const [newGroupName, setNewGroupName] = useState("");
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||||
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
|
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
|
||||||
@@ -423,6 +432,7 @@ function TenantGroupsPage() {
|
|||||||
id="name"
|
id="name"
|
||||||
value={newGroupName}
|
value={newGroupName}
|
||||||
onChange={(e) => setNewGroupName(e.target.value)}
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.groups.form.name_placeholder",
|
"ui.admin.groups.form.name_placeholder",
|
||||||
"예: 개발팀, 인사팀",
|
"예: 개발팀, 인사팀",
|
||||||
@@ -437,6 +447,7 @@ function TenantGroupsPage() {
|
|||||||
id="unitType"
|
id="unitType"
|
||||||
value={newGroupUnitType}
|
value={newGroupUnitType}
|
||||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.groups.form.unit_level_placeholder",
|
"ui.admin.groups.form.unit_level_placeholder",
|
||||||
"예: 본부, 팀, 셀",
|
"예: 본부, 팀, 셀",
|
||||||
@@ -449,9 +460,10 @@ function TenantGroupsPage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
id="parentId"
|
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 || ""}
|
value={newGroupParentId || ""}
|
||||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<option value="">{t("ui.common.none", "없음")}</option>
|
<option value="">{t("ui.common.none", "없음")}</option>
|
||||||
{groupsQuery.data?.map((group) => (
|
{groupsQuery.data?.map((group) => (
|
||||||
@@ -469,6 +481,7 @@ function TenantGroupsPage() {
|
|||||||
id="desc"
|
id="desc"
|
||||||
value={newGroupDesc}
|
value={newGroupDesc}
|
||||||
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.groups.form.desc_placeholder",
|
"ui.admin.groups.form.desc_placeholder",
|
||||||
"그룹 용도 설명",
|
"그룹 용도 설명",
|
||||||
@@ -478,7 +491,7 @@ function TenantGroupsPage() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
disabled={!newGroupName || createMutation.isPending}
|
disabled={!newGroupName || createMutation.isPending || !isWritable}
|
||||||
>
|
>
|
||||||
{t("ui.admin.groups.form.submit", "생성하기")}
|
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -569,6 +582,7 @@ function TenantGroupsPage() {
|
|||||||
onAddSubGroup={handleAddSubGroup}
|
onAddSubGroup={handleAddSubGroup}
|
||||||
addMemberMutation={addMemberMutation}
|
addMemberMutation={addMemberMutation}
|
||||||
removeMemberMutation={removeMemberMutation}
|
removeMemberMutation={removeMemberMutation}
|
||||||
|
isWritable={isWritable}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { DomainTagInput } from "../components/DomainTagInput";
|
import { DomainTagInput } from "../components/DomainTagInput";
|
||||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
import {
|
import {
|
||||||
formatDomainConflictMessage,
|
formatDomainConflictMessage,
|
||||||
type ServerDomainConflict,
|
type ServerDomainConflict,
|
||||||
@@ -52,6 +53,9 @@ export function TenantProfilePage() {
|
|||||||
enabled: tenantId.length > 0,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
|
const isWritable = hasPermission("manage");
|
||||||
|
|
||||||
const parentQuery = useQuery({
|
const parentQuery = useQuery({
|
||||||
queryKey: ["tenants", "list-all"],
|
queryKey: ["tenants", "list-all"],
|
||||||
queryFn: () => fetchAllTenants(),
|
queryFn: () => fetchAllTenants(),
|
||||||
@@ -261,13 +265,13 @@ export function TenantProfilePage() {
|
|||||||
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
<Input value={name} onChange={(e) => setName(e.target.value)} disabled={!isWritable} />
|
||||||
</div>
|
</div>
|
||||||
<div data-testid="tenant-slug-slot" className="space-y-1">
|
<div data-testid="tenant-slug-slot" className="space-y-1">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
<Input value={slug} onChange={(e) => setSlug(e.target.value)} disabled={!isWritable} />
|
||||||
</div>
|
</div>
|
||||||
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
|
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
|
||||||
<ParentTenantSelector
|
<ParentTenantSelector
|
||||||
@@ -283,6 +287,7 @@ export function TenantProfilePage() {
|
|||||||
excludeTenantId={tenantId}
|
excludeTenantId={tenantId}
|
||||||
compact
|
compact
|
||||||
controlTestId="tenant-parent-picker-control"
|
controlTestId="tenant-parent-picker-control"
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value)}
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<option value="COMPANY">
|
<option value="COMPANY">
|
||||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||||
@@ -346,9 +352,10 @@ export function TenantProfilePage() {
|
|||||||
id="tenant-org-unit-type"
|
id="tenant-org-unit-type"
|
||||||
name="tenant-org-unit-type"
|
name="tenant-org-unit-type"
|
||||||
data-testid="tenant-org-unit-type-select"
|
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}
|
value={orgUnitType}
|
||||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<option value="">{t("ui.common.none", "없음")}</option>
|
<option value="">{t("ui.common.none", "없음")}</option>
|
||||||
{orgUnitTypeOptions.map((option) => (
|
{orgUnitTypeOptions.map((option) => (
|
||||||
@@ -365,13 +372,14 @@ export function TenantProfilePage() {
|
|||||||
<select
|
<select
|
||||||
id="tenant-visibility"
|
id="tenant-visibility"
|
||||||
name="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}
|
value={tenantVisibility}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setTenantVisibility(
|
setTenantVisibility(
|
||||||
event.target.value as TenantVisibility,
|
event.target.value as TenantVisibility,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -392,11 +400,12 @@ export function TenantProfilePage() {
|
|||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
id="worksmobileExcluded"
|
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"}
|
value={worksmobileExcluded ? "excluded" : "enabled"}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setWorksmobileExcluded(event.target.value === "excluded")
|
setWorksmobileExcluded(event.target.value === "excluded")
|
||||||
}
|
}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
<option value="enabled">
|
<option value="enabled">
|
||||||
{t(
|
{t(
|
||||||
@@ -424,6 +433,7 @@ export function TenantProfilePage() {
|
|||||||
rows={2}
|
rows={2}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -442,6 +452,7 @@ export function TenantProfilePage() {
|
|||||||
confirmedConflicts={forceDomainConflicts}
|
confirmedConflicts={forceDomainConflicts}
|
||||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||||
placeholder="example.com, example.kr"
|
placeholder="example.com, example.kr"
|
||||||
|
disabled={!isWritable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -454,6 +465,7 @@ export function TenantProfilePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={status === "active" ? "default" : "outline"}
|
variant={status === "active" ? "default" : "outline"}
|
||||||
onClick={() => setStatus("active")}
|
onClick={() => setStatus("active")}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
{t("ui.common.status.active", "활성")}
|
{t("ui.common.status.active", "활성")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -462,6 +474,7 @@ export function TenantProfilePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={status === "inactive" ? "default" : "outline"}
|
variant={status === "inactive" ? "default" : "outline"}
|
||||||
onClick={() => setStatus("inactive")}
|
onClick={() => setStatus("inactive")}
|
||||||
|
disabled={!isWritable}
|
||||||
>
|
>
|
||||||
{t("ui.common.status.inactive", "비활성")}
|
{t("ui.common.status.inactive", "비활성")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -480,7 +493,7 @@ export function TenantProfilePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleteMutation.isPending || isProtectedSeedTenant}
|
disabled={deleteMutation.isPending || isProtectedSeedTenant || !isWritable}
|
||||||
title={
|
title={
|
||||||
isProtectedSeedTenant
|
isProtectedSeedTenant
|
||||||
? t(
|
? t(
|
||||||
@@ -499,7 +512,7 @@ export function TenantProfilePage() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700"
|
||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
disabled={approveMutation.isPending}
|
disabled={approveMutation.isPending || !isWritable}
|
||||||
>
|
>
|
||||||
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
|
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -512,7 +525,8 @@ export function TenantProfilePage() {
|
|||||||
disabled={
|
disabled={
|
||||||
updateMutation.isPending ||
|
updateMutation.isPending ||
|
||||||
tenantQuery.isLoading ||
|
tenantQuery.isLoading ||
|
||||||
name.trim() === ""
|
name.trim() === "" ||
|
||||||
|
!isWritable
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Save size={16} />
|
<Save size={16} />
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export type TenantSummary = {
|
|||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
memberCount: number; // 해당 테넌트 직접 소속 인원
|
memberCount: number; // 해당 테넌트 직접 소속 인원
|
||||||
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
|
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
|
||||||
|
userPermissions?: {
|
||||||
|
view: boolean;
|
||||||
|
manage: boolean;
|
||||||
|
manage_admins: boolean;
|
||||||
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,20 +84,27 @@ func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
|
|||||||
h.Worksmobile = syncer
|
h.Worksmobile = syncer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tenantPermissions struct {
|
||||||
|
View bool `json:"view"`
|
||||||
|
Manage bool `json:"manage"`
|
||||||
|
ManageAdmins bool `json:"manage_admins"`
|
||||||
|
}
|
||||||
|
|
||||||
type tenantSummary struct {
|
type tenantSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ParentID *string `json:"parentId"`
|
ParentID *string `json:"parentId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Domains []string `json:"domains,omitempty"`
|
Domains []string `json:"domains,omitempty"`
|
||||||
Config domain.JSONMap `json:"config,omitempty"`
|
Config domain.JSONMap `json:"config,omitempty"`
|
||||||
MemberCount int64 `json:"memberCount"`
|
MemberCount int64 `json:"memberCount"`
|
||||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||||
CreatedAt string `json:"createdAt"`
|
UserPermissions *tenantPermissions `json:"userPermissions,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantListResponse struct {
|
type tenantListResponse struct {
|
||||||
@@ -1678,6 +1685,53 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
|||||||
summary.MemberCount = memberCounts[tenant.ID]
|
summary.MemberCount = memberCounts[tenant.ID]
|
||||||
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
||||||
|
|
||||||
|
// Populate Keto-based permissions for the current user
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if ok && profile != nil {
|
||||||
|
role := domain.NormalizeRole(profile.Role)
|
||||||
|
if role == domain.RoleSuperAdmin {
|
||||||
|
summary.UserPermissions = &tenantPermissions{
|
||||||
|
View: true,
|
||||||
|
Manage: true,
|
||||||
|
ManageAdmins: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Query Keto in parallel for maximum performance
|
||||||
|
subject := "User:" + profile.ID
|
||||||
|
type checkResult struct {
|
||||||
|
relation string
|
||||||
|
allowed bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan checkResult, 3)
|
||||||
|
relations := []string{"view", "manage", "manage_admins"}
|
||||||
|
for _, rel := range relations {
|
||||||
|
go func(r string) {
|
||||||
|
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "Tenant", tenant.ID, r)
|
||||||
|
ch <- checkResult{relation: r, allowed: allowed, err: err}
|
||||||
|
}(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := &tenantPermissions{}
|
||||||
|
for range relations {
|
||||||
|
res := <-ch
|
||||||
|
if res.err != nil {
|
||||||
|
slog.Error("Failed to check Keto permission in GetTenant", "error", res.err, "relation", res.relation, "userID", profile.ID, "tenantID", tenant.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch res.relation {
|
||||||
|
case "view":
|
||||||
|
perms.View = res.allowed
|
||||||
|
case "manage":
|
||||||
|
perms.Manage = res.allowed
|
||||||
|
case "manage_admins":
|
||||||
|
perms.ManageAdmins = res.allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.UserPermissions = perms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(summary)
|
return c.JSON(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
164
backend/internal/handler/tenant_handler_get_test.go
Normal file
164
backend/internal/handler/tenant_handler_get_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/testsupport"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTenantHandler_GetTenant_SuperAdmin(t *testing.T) {
|
||||||
|
if !testsupport.DockerAvailable() {
|
||||||
|
t.Skip("Docker provider is unavailable in this environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate tenant domains: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test tenant in DB with a valid UUID
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000010",
|
||||||
|
Name: "Super Admin Test Tenant",
|
||||||
|
Slug: "super-admin-test-tenant",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
if err := db.Create(&tenant).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||||
|
|
||||||
|
// Mock projection status
|
||||||
|
mockProjection.On("IsReady", mock.Anything).Return(true, nil)
|
||||||
|
mockProjection.On("CountTenantMembers", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000010": 5}, nil)
|
||||||
|
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000010": 5}, nil)
|
||||||
|
|
||||||
|
h := &TenantHandler{
|
||||||
|
DB: db,
|
||||||
|
Service: mockSvc,
|
||||||
|
Keto: mockKeto,
|
||||||
|
UserProjectionRepo: mockProjection,
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll simulate middleware setting "user_profile" for a Super Admin
|
||||||
|
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||||
|
profile := &domain.UserProfileResponse{
|
||||||
|
ID: "user-super-admin-id",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
c.Locals("user_profile", profile)
|
||||||
|
return h.GetTenant(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000010", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var got tenantSummary
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "00000000-0000-0000-0000-000000000010", got.ID)
|
||||||
|
assert.Equal(t, "Super Admin Test Tenant", got.Name)
|
||||||
|
assert.NotNil(t, got.UserPermissions)
|
||||||
|
assert.True(t, got.UserPermissions.View)
|
||||||
|
assert.True(t, got.UserPermissions.Manage)
|
||||||
|
assert.True(t, got.UserPermissions.ManageAdmins)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantHandler_GetTenant_NormalUser(t *testing.T) {
|
||||||
|
if !testsupport.DockerAvailable() {
|
||||||
|
t.Skip("Docker provider is unavailable in this environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate tenant domains: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test tenant in DB with a valid UUID
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: "00000000-0000-0000-0000-000000000020",
|
||||||
|
Name: "Normal User Test Tenant",
|
||||||
|
Slug: "normal-user-test-tenant",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
if err := db.Create(&tenant).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||||
|
|
||||||
|
// Mock projection status
|
||||||
|
mockProjection.On("IsReady", mock.Anything).Return(true, nil)
|
||||||
|
mockProjection.On("CountTenantMembers", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000020": 2}, nil)
|
||||||
|
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000020": 2}, nil)
|
||||||
|
|
||||||
|
h := &TenantHandler{
|
||||||
|
DB: db,
|
||||||
|
Service: mockSvc,
|
||||||
|
Keto: mockKeto,
|
||||||
|
UserProjectionRepo: mockProjection,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock Keto response: allowed view/manage but not manage_admins
|
||||||
|
subject := "User:user-normal-id"
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil)
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil)
|
||||||
|
|
||||||
|
// We'll simulate middleware setting "user_profile" for a regular admin/user
|
||||||
|
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||||
|
profile := &domain.UserProfileResponse{
|
||||||
|
ID: "user-normal-id",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
}
|
||||||
|
c.Locals("user_profile", profile)
|
||||||
|
return h.GetTenant(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000020", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var got tenantSummary
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "00000000-0000-0000-0000-000000000020", got.ID)
|
||||||
|
assert.Equal(t, "Normal User Test Tenant", got.Name)
|
||||||
|
assert.NotNil(t, got.UserPermissions)
|
||||||
|
assert.True(t, got.UserPermissions.View)
|
||||||
|
assert.True(t, got.UserPermissions.Manage)
|
||||||
|
assert.False(t, got.UserPermissions.ManageAdmins)
|
||||||
|
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user