forked from baron/baron-sso
Merge origin/dev into dev
This commit is contained in:
@@ -14,6 +14,8 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage";
|
||||
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
@@ -51,6 +53,10 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
{
|
||||
path: "permissions-direct",
|
||||
element: <TenantFineGrainedPermissionsPage />,
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
@@ -59,6 +65,10 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{
|
||||
path: "relations",
|
||||
element: <TenantFineGrainedPermissionsTab />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,6 +116,7 @@ describe("admin AppLayout", () => {
|
||||
"Ory SSOT System",
|
||||
"Data Integrity",
|
||||
"Users",
|
||||
"권한 부여",
|
||||
"Auth Guard",
|
||||
"API Keys",
|
||||
"Audit Logs",
|
||||
|
||||
@@ -62,6 +62,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
|
||||
to: "/users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.permissions_direct",
|
||||
labelFallback: "권한 부여",
|
||||
to: "/permissions-direct",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.admin.nav.auth_guard",
|
||||
labelFallback: "Auth Guard",
|
||||
@@ -206,70 +212,72 @@ function AppLayout() {
|
||||
...profile,
|
||||
role: effectiveRole ?? profile?.role,
|
||||
});
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (item.to === "/api-keys") return isSuperAdmin;
|
||||
return true;
|
||||
});
|
||||
|
||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||
{ includeInternal: true },
|
||||
);
|
||||
|
||||
if (isSuperAdmin) {
|
||||
filteredItems.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
filteredItems.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
if (showWorksmobile) {
|
||||
filteredItems.splice(3, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
}
|
||||
filteredItems.splice(4, 0, {
|
||||
labelKey: "ui.admin.nav.ory_ssot",
|
||||
labelFallback: "Ory SSOT System",
|
||||
to: "/system/ory-ssot",
|
||||
icon: Database,
|
||||
});
|
||||
filteredItems.splice(5, 0, {
|
||||
labelKey: "ui.admin.nav.data_integrity",
|
||||
labelFallback: "Data Integrity",
|
||||
to: "/system/data-integrity",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
} else {
|
||||
// Non-superadmins
|
||||
filteredItems.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
if (showWorksmobile) {
|
||||
filteredItems.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Splice optional menus in a standard order
|
||||
items.splice(1, 0, {
|
||||
labelKey: "ui.admin.nav.tenants",
|
||||
labelFallback: "Tenants",
|
||||
to: "/tenants",
|
||||
icon: Building2,
|
||||
});
|
||||
items.splice(2, 0, {
|
||||
labelKey: "ui.admin.nav.org_chart",
|
||||
labelFallback: "Org Chart",
|
||||
to: orgfrontUrl,
|
||||
icon: Network,
|
||||
isExternal: true,
|
||||
});
|
||||
items.splice(3, 0, {
|
||||
labelKey: "ui.admin.nav.worksmobile",
|
||||
labelFallback: "Worksmobile",
|
||||
to: "/worksmobile",
|
||||
icon: LineWorksNavIcon,
|
||||
});
|
||||
items.splice(4, 0, {
|
||||
labelKey: "ui.admin.nav.ory_ssot",
|
||||
labelFallback: "Ory SSOT System",
|
||||
to: "/system/ory-ssot",
|
||||
icon: Database,
|
||||
});
|
||||
items.splice(5, 0, {
|
||||
labelKey: "ui.admin.nav.data_integrity",
|
||||
labelFallback: "Data Integrity",
|
||||
to: "/system/data-integrity",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
const permissions = profile?.systemPermissions;
|
||||
|
||||
return items.filter((item) => {
|
||||
// Super Admin ALWAYS bypasses and gets full access to everything
|
||||
if (isSuperAdmin) {
|
||||
if (item.to === "/worksmobile") return showWorksmobile;
|
||||
return true;
|
||||
}
|
||||
|
||||
// For others, check their fine-grained systemPermissions
|
||||
if (!permissions) return false;
|
||||
|
||||
if (item.to === "/") return permissions.overview;
|
||||
if (item.to === "/users") return permissions.users;
|
||||
if (item.to === "/auth") return permissions.auth_guard;
|
||||
if (item.to === "/api-keys") return permissions.api_keys;
|
||||
if (item.to === "/audit-logs") return permissions.audit_logs;
|
||||
if (item.to === "/permissions-direct") return false;
|
||||
if (item.to === "/tenants") return permissions.tenants;
|
||||
if (item.to === orgfrontUrl) return permissions.org_chart;
|
||||
if (item.to === "/worksmobile")
|
||||
return permissions.worksmobile && showWorksmobile;
|
||||
if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
|
||||
if (item.to === "/system/data-integrity")
|
||||
return permissions.data_integrity;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [profile]);
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ const members = [
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })),
|
||||
fetchTenant: vi.fn(async () => tenant),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
|
||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||
|
||||
const exportUsersCSVMock = vi.hoisted(() =>
|
||||
@@ -94,12 +95,29 @@ 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),
|
||||
addTenantAdmin: vi.fn(async () => undefined),
|
||||
removeTenantOwner: vi.fn(async () => undefined),
|
||||
removeTenantAdmin: vi.fn(async () => undefined),
|
||||
fetchTenantRelations: vi.fn(async () => [
|
||||
{
|
||||
userId: "user-relation-1",
|
||||
name: "Relation User",
|
||||
email: "relation@example.com",
|
||||
relations: ["profile_managers", "schema_viewers"],
|
||||
},
|
||||
]),
|
||||
addTenantRelation: vi.fn(async () => undefined),
|
||||
removeTenantRelation: vi.fn(async () => undefined),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: users,
|
||||
total: users.length,
|
||||
@@ -160,6 +178,22 @@ describe("admin tenant tab coverage smoke", () => {
|
||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant fine-grained relations list", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/relations"
|
||||
element={<TenantFineGrainedPermissionsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/relations",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Relation User")).toBeInTheDocument();
|
||||
expect(screen.getByText("relation@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("세부 권한 설정 (Fine-grained Permissions)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant hierarchy and selected organization members", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -36,6 +36,7 @@ type ParentTenantSelectorProps = {
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
compact?: boolean;
|
||||
controlTestId?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ParentTenantSelector({
|
||||
@@ -55,6 +56,7 @@ export function ParentTenantSelector({
|
||||
localTenantFilter,
|
||||
compact = false,
|
||||
controlTestId,
|
||||
disabled = false,
|
||||
}: ParentTenantSelectorProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||
@@ -117,6 +119,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 ??
|
||||
@@ -147,7 +150,12 @@ 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>
|
||||
@@ -234,6 +242,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,29 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
type TenantPermissionKey,
|
||||
useTenantPermission,
|
||||
} from "../hooks/useTenantPermission";
|
||||
|
||||
interface TenantPermissionGuardProps {
|
||||
tenantId: string;
|
||||
relation: TenantPermissionKey;
|
||||
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,133 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
|
||||
import { useTenantPermission } from "./useTenantPermission";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
39
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
39
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export type TenantPermissionKey =
|
||||
| "view"
|
||||
| "manage"
|
||||
| "manage_admins"
|
||||
| "view_profile"
|
||||
| "manage_profile"
|
||||
| "view_permissions"
|
||||
| "manage_permissions"
|
||||
| "view_organization"
|
||||
| "manage_organization"
|
||||
| "view_schema"
|
||||
| "manage_schema";
|
||||
|
||||
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: TenantPermissionKey): boolean => {
|
||||
// Super Admin always has full bypass access
|
||||
if (normalizeAdminRole(profile?.role) === "super_admin") {
|
||||
return true;
|
||||
}
|
||||
return !!tenant?.userPermissions?.[requiredRelation];
|
||||
};
|
||||
|
||||
return { hasPermission, isLoading: !tenant };
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type TenantAdmin,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
type DialogMode = "owner" | "admin";
|
||||
|
||||
@@ -69,6 +70,10 @@ 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_permissions") || hasPermission("manage_admins");
|
||||
const canView = hasPermission("view_permissions") || hasPermission("view");
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||
@@ -338,6 +343,16 @@ export function TenantAdminsAndOwnersTab() {
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||
<h3 className="text-xl font-bold text-destructive">
|
||||
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const serverOwners = ownersQuery.data || [];
|
||||
const serverAdmins = adminsQuery.data || [];
|
||||
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||
@@ -362,7 +377,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
||||
{/* Owners Card */}
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||
@@ -382,6 +397,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 +487,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", "관리자 추가")}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { 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 +17,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 +104,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 ${
|
||||
@@ -122,6 +116,18 @@ function TenantDetailPage() {
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission("view") && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/relations`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
location.pathname.includes("/relations")
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_relations", "세부 권한")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Building2,
|
||||
Database,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Card, CardContent } from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addSystemRelation,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchSystemRelations,
|
||||
fetchUsers,
|
||||
removeSystemRelation,
|
||||
type TenantRelation,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export function TenantFineGrainedPermissionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system");
|
||||
const [_selectedTenantId, _setSelectedTenantId] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [activeUserId, setActiveUserId] = useState<string | null>(null);
|
||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||
|
||||
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
|
||||
const [localSystemPermissions, setLocalSystemPermissions] = useState<
|
||||
Record<string, Record<string, "none" | "read" | "write">>
|
||||
>({});
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const isSuperAdmin = profile?.role === "super_admin";
|
||||
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
enabled: isSuperAdmin,
|
||||
});
|
||||
|
||||
const _tenants = isSuperAdmin
|
||||
? (tenantsQuery.data?.items ?? [])
|
||||
: (profile?.manageableTenants ?? []);
|
||||
|
||||
// System Relations (Admin Control) Queries & Mutations
|
||||
const systemRelationsQuery = useQuery({
|
||||
queryKey: ["system-relations"],
|
||||
queryFn: fetchSystemRelations,
|
||||
enabled: isSuperAdmin && activeTab === "system",
|
||||
});
|
||||
const systemRelations = systemRelationsQuery.data ?? [];
|
||||
|
||||
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
|
||||
useEffect(() => {
|
||||
if (systemRelationsQuery.data) {
|
||||
const initialMap: Record<
|
||||
string,
|
||||
Record<string, "none" | "read" | "write">
|
||||
> = {};
|
||||
for (const user of systemRelationsQuery.data) {
|
||||
initialMap[user.userId] = {};
|
||||
const menus = [
|
||||
"overview",
|
||||
"audit_logs",
|
||||
"tenants",
|
||||
"org_chart",
|
||||
"users",
|
||||
"worksmobile",
|
||||
"api_keys",
|
||||
"ory_ssot",
|
||||
"data_integrity",
|
||||
"auth_guard",
|
||||
"permissions_direct",
|
||||
];
|
||||
for (const m of menus) {
|
||||
const isWrite = user.relations.includes(`${m}_managers`);
|
||||
const isRead = user.relations.includes(`${m}_viewers`);
|
||||
initialMap[user.userId][m] = isWrite
|
||||
? "write"
|
||||
: isRead
|
||||
? "read"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
setLocalSystemPermissions(initialMap);
|
||||
}
|
||||
}, [systemRelationsQuery.data]);
|
||||
|
||||
const addSystemRelationMutation = useMutation({
|
||||
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||
addSystemRelation(payload.userId, payload.relation),
|
||||
onMutate: async (newRelation) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
|
||||
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
|
||||
"system-relations",
|
||||
]);
|
||||
|
||||
queryClient.setQueryData<TenantRelation[]>(
|
||||
["system-relations"],
|
||||
(old) => {
|
||||
if (!old) return [];
|
||||
return old.map((user) => {
|
||||
if (user.userId === newRelation.userId) {
|
||||
return {
|
||||
...user,
|
||||
relations: user.relations.includes(newRelation.relation)
|
||||
? user.relations
|
||||
: [...user.relations, newRelation.relation],
|
||||
};
|
||||
}
|
||||
return user;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return { previousRelations };
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, _, context) => {
|
||||
if (context?.previousRelations) {
|
||||
queryClient.setQueryData(
|
||||
["system-relations"],
|
||||
context.previousRelations,
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Quiet mutate
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
||||
const removeSystemRelationMutation = useMutation({
|
||||
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||
removeSystemRelation(payload.userId, payload.relation),
|
||||
onMutate: async (targetRelation) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
|
||||
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
|
||||
"system-relations",
|
||||
]);
|
||||
|
||||
queryClient.setQueryData<TenantRelation[]>(
|
||||
["system-relations"],
|
||||
(old) => {
|
||||
if (!old) return [];
|
||||
return old.map((user) => {
|
||||
if (user.userId === targetRelation.userId) {
|
||||
return {
|
||||
...user,
|
||||
relations: user.relations.filter(
|
||||
(r) => r !== targetRelation.relation,
|
||||
),
|
||||
};
|
||||
}
|
||||
return user;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return { previousRelations };
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, _, context) => {
|
||||
if (context?.previousRelations) {
|
||||
queryClient.setQueryData(
|
||||
["system-relations"],
|
||||
context.previousRelations,
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Quiet mutate
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSystemRelationChange = async (
|
||||
userId: string,
|
||||
menuKey: string,
|
||||
currentVal: "none" | "read" | "write",
|
||||
newVal: "none" | "read" | "write",
|
||||
) => {
|
||||
if (currentVal === newVal) return;
|
||||
|
||||
try {
|
||||
if (currentVal === "read") {
|
||||
await removeSystemRelationMutation.mutateAsync({
|
||||
userId,
|
||||
relation: `${menuKey}_viewers`,
|
||||
});
|
||||
} else if (currentVal === "write") {
|
||||
await removeSystemRelationMutation.mutateAsync({
|
||||
userId,
|
||||
relation: `${menuKey}_managers`,
|
||||
});
|
||||
}
|
||||
|
||||
if (newVal === "read") {
|
||||
await addSystemRelationMutation.mutateAsync({
|
||||
userId,
|
||||
relation: `${menuKey}_viewers`,
|
||||
});
|
||||
} else if (newVal === "write") {
|
||||
await addSystemRelationMutation.mutateAsync({
|
||||
userId,
|
||||
relation: `${menuKey}_managers`,
|
||||
});
|
||||
}
|
||||
|
||||
// 🌟 Trigger a single consolidated success toast at the very end
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.system.relations.update_success",
|
||||
"시스템 메뉴 권한이 성공적으로 변경되었습니다.",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Individual mutations handle error toast via onError
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAllSystemRelations = async (
|
||||
userId: string,
|
||||
userRelations: string[],
|
||||
) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.system.relations.remove_all_confirm",
|
||||
"이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const rel of userRelations) {
|
||||
await removeSystemRelationMutation.mutateAsync({ userId, relation: rel });
|
||||
}
|
||||
if (activeUserId === userId) {
|
||||
setActiveUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["admin-users-search", searchTerm],
|
||||
queryFn: () => fetchUsers(20, 0, searchTerm),
|
||||
enabled: isDialogOpen && searchTerm.length >= 2,
|
||||
});
|
||||
|
||||
const handleAddSystemUser = (userId: string) => {
|
||||
addSystemRelationMutation.mutate({ userId, relation: "overview_viewers" });
|
||||
setActiveUserId(userId);
|
||||
setIsDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const searchResults = usersQuery.data?.items || [];
|
||||
|
||||
// Categorized system menus with descriptions and icons
|
||||
const systemMenuCategories = [
|
||||
{
|
||||
title: t(
|
||||
"ui.admin.permissions_direct.cat_dashboard",
|
||||
"핵심 대시보드 및 분석",
|
||||
),
|
||||
menus: [
|
||||
{
|
||||
label: t("ui.admin.nav.overview", "개요"),
|
||||
relation: "overview",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_overview",
|
||||
"바론 전체 사양 및 시스템 상태 개요 정보",
|
||||
),
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
label: t("ui.admin.nav.audit_logs", "감사 로그"),
|
||||
relation: "audit_logs",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_audit_logs",
|
||||
"시스템 전역 보안 감사 및 접속 이력 로그",
|
||||
),
|
||||
icon: NotebookTabs,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"),
|
||||
menus: [
|
||||
{
|
||||
label: t("ui.admin.nav.tenants", "테넌트"),
|
||||
relation: "tenants",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_tenants",
|
||||
"고객 테넌트 목록, 신규 부모-자식 테넌트 관리",
|
||||
),
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
label: t("ui.admin.nav.org_chart", "조직도"),
|
||||
relation: "org_chart",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_org_chart",
|
||||
"조직도 가시화 및 트리 배치 확인",
|
||||
),
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
label: t("ui.admin.nav.users", "사용자"),
|
||||
relation: "users",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_users",
|
||||
"가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입",
|
||||
),
|
||||
icon: Users,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
"ui.admin.permissions_direct.cat_integrations",
|
||||
"인프라 연동 및 보안",
|
||||
),
|
||||
menus: [
|
||||
{
|
||||
label: t("ui.admin.nav.worksmobile", "Worksmobile"),
|
||||
relation: "worksmobile",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_worksmobile",
|
||||
"라인웍스 연동 및 사내 임직원 패스워드 강제 동기화",
|
||||
),
|
||||
icon: Share2,
|
||||
},
|
||||
{
|
||||
label: t("ui.admin.nav.api_keys", "API 키"),
|
||||
relation: "api_keys",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_api_keys",
|
||||
"조직도 연동을 위한 전역 서드파티 토큰 관리",
|
||||
),
|
||||
icon: Key,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
"ui.admin.permissions_direct.cat_system",
|
||||
"아이덴티티 및 게이트 관리",
|
||||
),
|
||||
menus: [
|
||||
{
|
||||
label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"),
|
||||
relation: "ory_ssot",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_ory_ssot",
|
||||
"Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신",
|
||||
),
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
label: t("ui.admin.nav.data_integrity", "데이터 정합성"),
|
||||
relation: "data_integrity",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_data_integrity",
|
||||
"고아 레코드 검출 및 DB 정합성 최종 검증기",
|
||||
),
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: t("ui.admin.nav.auth_guard", "인증 가드"),
|
||||
relation: "auth_guard",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_auth_guard",
|
||||
"정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터",
|
||||
),
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
label: t("ui.admin.nav.permissions_direct", "권한 부여"),
|
||||
relation: "permissions_direct",
|
||||
desc: t(
|
||||
"msg.admin.permissions_direct.desc_permissions_direct",
|
||||
"본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널",
|
||||
),
|
||||
icon: Shield,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const filteredRelations = systemRelations.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(userSearchTerm.toLowerCase()) ||
|
||||
r.email.toLowerCase().includes(userSearchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const selectedUser = systemRelations.find((r) => r.userId === activeUserId);
|
||||
|
||||
if (profile && !isSuperAdmin) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<h3 className="text-lg font-bold">
|
||||
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
<Button onClick={() => navigate("/")}>
|
||||
{t("ui.common.go_home", "홈으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<ShieldCheck className="h-8 w-8 text-primary" />
|
||||
{t("ui.admin.nav.permissions_direct", "권한 부여")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.description",
|
||||
"테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */}
|
||||
<div className="flex flex-col lg:flex-row gap-6 h-[720px] border border-border rounded-xl bg-card overflow-hidden shadow-sm">
|
||||
{/* Left Panel: User List */}
|
||||
<div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
|
||||
<div className="p-4 border-b border-border space-y-3 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-bold text-sm text-foreground">
|
||||
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} (
|
||||
{filteredRelations.length})
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
|
||||
value={userSearchTerm}
|
||||
onChange={(e) => setUserSearchTerm(e.target.value)}
|
||||
name="user-search"
|
||||
className="pl-8 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredRelations.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.no_users_found",
|
||||
"등록된 사용자가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredRelations.map((user) => {
|
||||
const isSelected = activeUserId === user.userId;
|
||||
const activeCount = user.relations.length;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={user.userId}
|
||||
onClick={() => setActiveUserId(user.userId)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-all ${
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary border-l-4 border-primary shadow-sm"
|
||||
: "hover:bg-muted/50 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar className="h-8 w-8 border border-border">
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-bold text-xs uppercase">
|
||||
{user.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[150px]">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={isSelected ? "default" : "secondary"}
|
||||
className="text-[9px] px-1.5 py-0.5"
|
||||
>
|
||||
{activeCount}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Toggle settings grid */}
|
||||
<div className="flex-1 flex flex-col h-full bg-background">
|
||||
{selectedUser ? (
|
||||
<>
|
||||
{/* User Detail Header */}
|
||||
<div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-11 w-11 border">
|
||||
<AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
|
||||
{selectedUser.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
{selectedUser.name}
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{selectedUser.relations.length}{" "}
|
||||
{t("ui.admin.permissions_direct.allowed", "개 허용됨")}
|
||||
</Badge>
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedUser.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/20 hover:bg-destructive/10"
|
||||
onClick={() =>
|
||||
handleRemoveAllSystemRelations(
|
||||
selectedUser.userId,
|
||||
selectedUser.relations,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
"ui.admin.permissions_direct.revoke_all",
|
||||
"모든 권한 회수",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Categorized Toggle Grid */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 space-y-6">
|
||||
{systemMenuCategories.map((category) => (
|
||||
<div key={category.title} className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
{category.title}
|
||||
</h4>
|
||||
<Card className="border border-border/60 shadow-none bg-card">
|
||||
<CardContent className="p-0 divide-y divide-border/40">
|
||||
{category.menus.map((menu) => {
|
||||
const isWrite = selectedUser.relations.includes(
|
||||
`${menu.relation}_managers`,
|
||||
);
|
||||
const isRead = selectedUser.relations.includes(
|
||||
`${menu.relation}_viewers`,
|
||||
);
|
||||
const serverValue: "none" | "read" | "write" =
|
||||
isWrite ? "write" : isRead ? "read" : "none";
|
||||
const permissionValue =
|
||||
localSystemPermissions[selectedUser.userId]?.[
|
||||
menu.relation
|
||||
] ?? serverValue;
|
||||
const Icon = menu.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={menu.relation}
|
||||
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4 pr-4 min-w-0">
|
||||
<div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{menu.label}
|
||||
</span>
|
||||
{(menu.relation === "ory_ssot" ||
|
||||
menu.relation === "data_integrity") && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_only",
|
||||
"Super Admin 전용",
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{menu.desc}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
name={`system-menu-permission-${menu.relation}`}
|
||||
value={permissionValue}
|
||||
disabled={
|
||||
menu.relation === "ory_ssot" ||
|
||||
menu.relation === "data_integrity"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const nextVal = e.target.value as
|
||||
| "none"
|
||||
| "read"
|
||||
| "write";
|
||||
// 🌟 1단계: 로컬 임시 상태 즉시 갱신 (0ms 반응 보장)
|
||||
setLocalSystemPermissions((prev) => ({
|
||||
...prev,
|
||||
[selectedUser.userId]: {
|
||||
...(prev[selectedUser.userId] ?? {}),
|
||||
[menu.relation]: nextVal,
|
||||
},
|
||||
}));
|
||||
// 🌟 2단계: 백그라운드 비동기 API 요청 수행
|
||||
handleSystemRelationChange(
|
||||
selectedUser.userId,
|
||||
menu.relation,
|
||||
permissionValue,
|
||||
nextVal,
|
||||
);
|
||||
}}
|
||||
className="flex h-9 w-[180px] rounded-md border border-input bg-background 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"
|
||||
>
|
||||
<option value="none">
|
||||
{t("ui.common.none", "권한 없음")}
|
||||
</option>
|
||||
<option value="read">
|
||||
{t("ui.common.read", "조회 가능 (Read)")}
|
||||
</option>
|
||||
<option value="write">
|
||||
{t("ui.common.write", "수정 가능 (Write)")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center text-muted-foreground bg-muted/5 gap-3">
|
||||
<ShieldCheck className="h-12 w-12 text-muted-foreground opacity-30" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.no_user_selected",
|
||||
"사용자가 선택되지 않았습니다.",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs mt-1">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.no_user_selected_desc",
|
||||
"왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Search Dialog for System relations */}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.dialog_title_system",
|
||||
"시스템 권한 관리 유저 추가",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"ui.admin.tenants.admins.dialog_description",
|
||||
"이름 또는 이메일로 사용자를 검색하세요.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.admins.dialog_search_placeholder",
|
||||
"사용자 검색 (최소 2자)...",
|
||||
)}
|
||||
className="pl-10 h-11"
|
||||
autoFocus
|
||||
value={searchTerm}
|
||||
name="system-user-dialog-search"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
|
||||
{searchTerm.length < 2 ? (
|
||||
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Search className="h-8 w-8 opacity-20" />
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
"ui.admin.tenants.admins.dialog_search_hint",
|
||||
"검색어를 입력해 주세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : usersQuery.isLoading ? (
|
||||
<div className="p-10 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-10 text-center text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.admins.dialog_no_results",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{searchResults.map((user) => {
|
||||
const isAlreadyInMatrix = systemRelations.some(
|
||||
(r) => r.userId === user.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{user.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isAlreadyInMatrix ? "ghost" : "outline"}
|
||||
disabled={
|
||||
isAlreadyInMatrix ||
|
||||
addSystemRelationMutation.isPending
|
||||
}
|
||||
onClick={() => handleAddSystemUser(user.id)}
|
||||
>
|
||||
{isAlreadyInMatrix ? (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{t(
|
||||
"ui.admin.tenants.relations.already_added",
|
||||
"이미 추가됨",
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3 w-3 mr-1" />{" "}
|
||||
{t("ui.common.add", "추가")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addTenantRelation,
|
||||
fetchTenantRelations,
|
||||
fetchUsers,
|
||||
removeTenantRelation,
|
||||
type TenantRelation,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
interface TenantFineGrainedPermissionsTabProps {
|
||||
tenantIdProp?: string;
|
||||
}
|
||||
|
||||
export function TenantFineGrainedPermissionsTab({
|
||||
tenantIdProp,
|
||||
}: TenantFineGrainedPermissionsTabProps = {}) {
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdProp || tenantIdParam || "";
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable = hasPermission("manage_admins");
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// 🌟 테넌트 탭별 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
|
||||
const [localTenantPermissions, setLocalTenantPermissions] = useState<
|
||||
Record<string, Record<string, "none" | "read" | "write">>
|
||||
>({});
|
||||
|
||||
const relationsQuery = useQuery({
|
||||
queryKey: ["tenant-relations", tenantId],
|
||||
queryFn: () => fetchTenantRelations(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
const _relationsData = relationsQuery.data ?? [];
|
||||
|
||||
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
|
||||
useEffect(() => {
|
||||
if (relationsQuery.data) {
|
||||
const initialMap: Record<
|
||||
string,
|
||||
Record<string, "none" | "read" | "write">
|
||||
> = {};
|
||||
for (const user of relationsQuery.data) {
|
||||
initialMap[user.userId] = {};
|
||||
const tabs = ["profile", "permissions", "organization", "schema"];
|
||||
for (const tab of tabs) {
|
||||
const isWrite = user.relations.includes(`${tab}_managers`);
|
||||
const isRead = user.relations.includes(`${tab}_viewers`);
|
||||
initialMap[user.userId][tab] = isWrite
|
||||
? "write"
|
||||
: isRead
|
||||
? "read"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
setLocalTenantPermissions(initialMap);
|
||||
}
|
||||
}, [relationsQuery.data]);
|
||||
const relations = relationsQuery.data ?? [];
|
||||
|
||||
const invalidateAllQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["tenant-relations", tenantId],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const addRelationMutation = useMutation({
|
||||
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||
addTenantRelation(tenantId, payload.userId, payload.relation),
|
||||
onMutate: async (newRelation) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["tenant-relations", tenantId],
|
||||
});
|
||||
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
|
||||
"tenant-relations",
|
||||
tenantId,
|
||||
]);
|
||||
|
||||
queryClient.setQueryData<TenantRelation[]>(
|
||||
["tenant-relations", tenantId],
|
||||
(old) => {
|
||||
if (!old) return [];
|
||||
return old.map((user) => {
|
||||
if (user.userId === newRelation.userId) {
|
||||
return {
|
||||
...user,
|
||||
relations: user.relations.includes(newRelation.relation)
|
||||
? user.relations
|
||||
: [...user.relations, newRelation.relation],
|
||||
};
|
||||
}
|
||||
return user;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return { previousRelations };
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, _, context) => {
|
||||
if (context?.previousRelations) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-relations", tenantId],
|
||||
context.previousRelations,
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Quiet mutate
|
||||
},
|
||||
});
|
||||
|
||||
const removeRelationMutation = useMutation({
|
||||
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||
removeTenantRelation(tenantId, payload.userId, payload.relation),
|
||||
onMutate: async (targetRelation) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["tenant-relations", tenantId],
|
||||
});
|
||||
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
|
||||
"tenant-relations",
|
||||
tenantId,
|
||||
]);
|
||||
|
||||
queryClient.setQueryData<TenantRelation[]>(
|
||||
["tenant-relations", tenantId],
|
||||
(old) => {
|
||||
if (!old) return [];
|
||||
return old.map((user) => {
|
||||
if (user.userId === targetRelation.userId) {
|
||||
return {
|
||||
...user,
|
||||
relations: user.relations.filter(
|
||||
(r) => r !== targetRelation.relation,
|
||||
),
|
||||
};
|
||||
}
|
||||
return user;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return { previousRelations };
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, _, context) => {
|
||||
if (context?.previousRelations) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-relations", tenantId],
|
||||
context.previousRelations,
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Quiet mutate
|
||||
},
|
||||
});
|
||||
|
||||
const handleRelationChange = async (
|
||||
userId: string,
|
||||
tab: "profile" | "permissions" | "organization" | "schema",
|
||||
currentVal: "none" | "read" | "write",
|
||||
newVal: "none" | "read" | "write",
|
||||
) => {
|
||||
const readRel = `${tab}_viewers`;
|
||||
const writeRel = `${tab}_managers`;
|
||||
|
||||
if (currentVal === newVal) return;
|
||||
|
||||
try {
|
||||
if (currentVal === "read") {
|
||||
await removeRelationMutation.mutateAsync({ userId, relation: readRel });
|
||||
} else if (currentVal === "write") {
|
||||
await removeRelationMutation.mutateAsync({
|
||||
userId,
|
||||
relation: writeRel,
|
||||
});
|
||||
}
|
||||
|
||||
if (newVal === "read") {
|
||||
await addRelationMutation.mutateAsync({ userId, relation: readRel });
|
||||
} else if (newVal === "write") {
|
||||
await addRelationMutation.mutateAsync({ userId, relation: writeRel });
|
||||
}
|
||||
|
||||
invalidateAllQueries();
|
||||
|
||||
// 🌟 Trigger a single consolidated success toast at the very end
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.tenants.relations.update_success",
|
||||
"세부 권한이 성공적으로 변경되었습니다.",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Individual mutations handle error toast via onError
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAllRelations = async (
|
||||
userId: string,
|
||||
userRelations: string[],
|
||||
) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.relations.remove_all_confirm",
|
||||
"이 사용자의 모든 세부 권한을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const rel of userRelations) {
|
||||
await removeRelationMutation.mutateAsync({ userId, relation: rel });
|
||||
}
|
||||
invalidateAllQueries();
|
||||
};
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["admin-users-search", searchTerm],
|
||||
queryFn: () => fetchUsers(20, 0, searchTerm),
|
||||
enabled: isDialogOpen && searchTerm.length >= 2,
|
||||
});
|
||||
|
||||
const handleAddUser = (userId: string) => {
|
||||
addRelationMutation.mutate(
|
||||
{ userId, relation: "profile_viewers" },
|
||||
{
|
||||
onSettled: () => {
|
||||
invalidateAllQueries();
|
||||
},
|
||||
},
|
||||
);
|
||||
setIsDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
if (!tenantId) return null;
|
||||
|
||||
const searchResults = usersQuery.data?.items || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
{t(
|
||||
"ui.admin.tenants.relations.title",
|
||||
"세부 권한 설정 (Fine-grained Permissions)",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.relations.subtitle",
|
||||
"사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t(
|
||||
"ui.admin.tenants.relations.add_button",
|
||||
"세부 권한 사용자 추가",
|
||||
)}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-secondary/40">
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.common.name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold text-center w-20">
|
||||
{t("ui.common.action", "작업")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{relations.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center py-12 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.relations.empty",
|
||||
"세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
relations.map((user) => {
|
||||
const profileVal = user.relations.includes(
|
||||
"profile_managers",
|
||||
)
|
||||
? "write"
|
||||
: user.relations.includes("profile_viewers")
|
||||
? "read"
|
||||
: "none";
|
||||
|
||||
const permissionsVal = user.relations.includes(
|
||||
"permissions_managers",
|
||||
)
|
||||
? "write"
|
||||
: user.relations.includes("permissions_viewers")
|
||||
? "read"
|
||||
: "none";
|
||||
|
||||
const organizationVal = user.relations.includes(
|
||||
"organization_managers",
|
||||
)
|
||||
? "write"
|
||||
: user.relations.includes("organization_viewers")
|
||||
? "read"
|
||||
: "none";
|
||||
|
||||
const schemaVal = user.relations.includes("schema_managers")
|
||||
? "write"
|
||||
: user.relations.includes("schema_viewers")
|
||||
? "read"
|
||||
: "none";
|
||||
|
||||
const curProfileVal =
|
||||
localTenantPermissions[user.userId]?.profile ??
|
||||
profileVal;
|
||||
const curPermissionsVal =
|
||||
localTenantPermissions[user.userId]?.permissions ??
|
||||
permissionsVal;
|
||||
const curOrganizationVal =
|
||||
localTenantPermissions[user.userId]?.organization ??
|
||||
organizationVal;
|
||||
const curSchemaVal =
|
||||
localTenantPermissions[user.userId]?.schema ?? schemaVal;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={user.userId}
|
||||
className="hover:bg-muted/10 transition-colors"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-foreground">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background 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={curProfileVal}
|
||||
disabled={!isWritable}
|
||||
name={`tenant-fine-grained-profile-${user.userId}`}
|
||||
onChange={(e) => {
|
||||
const nextVal = e.target.value as
|
||||
| "none"
|
||||
| "read"
|
||||
| "write";
|
||||
setLocalTenantPermissions((prev) => ({
|
||||
...prev,
|
||||
[user.userId]: {
|
||||
...(prev[user.userId] ?? {}),
|
||||
profile: nextVal,
|
||||
},
|
||||
}));
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"profile",
|
||||
profileVal,
|
||||
nextVal,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="none">
|
||||
{t("ui.common.none", "권한 없음")}
|
||||
</option>
|
||||
<option value="read">
|
||||
{t("ui.common.read", "조회 가능 (Read)")}
|
||||
</option>
|
||||
<option value="write">
|
||||
{t("ui.common.write", "수정 가능 (Write)")}
|
||||
</option>
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background 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={curPermissionsVal}
|
||||
disabled={!isWritable}
|
||||
name={`tenant-fine-grained-permissions-${user.userId}`}
|
||||
onChange={(e) => {
|
||||
const nextVal = e.target.value as
|
||||
| "none"
|
||||
| "read"
|
||||
| "write";
|
||||
setLocalTenantPermissions((prev) => ({
|
||||
...prev,
|
||||
[user.userId]: {
|
||||
...(prev[user.userId] ?? {}),
|
||||
permissions: nextVal,
|
||||
},
|
||||
}));
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"permissions",
|
||||
permissionsVal,
|
||||
nextVal,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="none">
|
||||
{t("ui.common.none", "권한 없음")}
|
||||
</option>
|
||||
<option value="read">
|
||||
{t("ui.common.read", "조회 가능 (Read)")}
|
||||
</option>
|
||||
<option value="write">
|
||||
{t("ui.common.write", "수정 가능 (Write)")}
|
||||
</option>
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background 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={curOrganizationVal}
|
||||
disabled={!isWritable}
|
||||
name={`tenant-fine-grained-organization-${user.userId}`}
|
||||
onChange={(e) => {
|
||||
const nextVal = e.target.value as
|
||||
| "none"
|
||||
| "read"
|
||||
| "write";
|
||||
setLocalTenantPermissions((prev) => ({
|
||||
...prev,
|
||||
[user.userId]: {
|
||||
...(prev[user.userId] ?? {}),
|
||||
organization: nextVal,
|
||||
},
|
||||
}));
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"organization",
|
||||
organizationVal,
|
||||
nextVal,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="none">
|
||||
{t("ui.common.none", "권한 없음")}
|
||||
</option>
|
||||
<option value="read">
|
||||
{t("ui.common.read", "조회 가능 (Read)")}
|
||||
</option>
|
||||
<option value="write">
|
||||
{t("ui.common.write", "수정 가능 (Write)")}
|
||||
</option>
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background 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={curSchemaVal}
|
||||
disabled={!isWritable}
|
||||
name={`tenant-fine-grained-schema-${user.userId}`}
|
||||
onChange={(e) => {
|
||||
const nextVal = e.target.value as
|
||||
| "none"
|
||||
| "read"
|
||||
| "write";
|
||||
setLocalTenantPermissions((prev) => ({
|
||||
...prev,
|
||||
[user.userId]: {
|
||||
...(prev[user.userId] ?? {}),
|
||||
schema: nextVal,
|
||||
},
|
||||
}));
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"schema",
|
||||
schemaVal,
|
||||
nextVal,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="none">
|
||||
{t("ui.common.none", "권한 없음")}
|
||||
</option>
|
||||
<option value="read">
|
||||
{t("ui.common.read", "조회 가능 (Read)")}
|
||||
</option>
|
||||
<option value="write">
|
||||
{t("ui.common.write", "수정 가능 (Write)")}
|
||||
</option>
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!isWritable}
|
||||
onClick={() =>
|
||||
handleRemoveAllRelations(
|
||||
user.userId,
|
||||
user.relations,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Common Dialog for adding users */}
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{t(
|
||||
"ui.admin.tenants.relations.dialog_title",
|
||||
"세부 권한 관리 유저 추가",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"ui.admin.tenants.admins.dialog_description",
|
||||
"이름 또는 이메일로 사용자를 검색하세요.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.admins.dialog_search_placeholder",
|
||||
"사용자 검색 (최소 2자)...",
|
||||
)}
|
||||
className="pl-10 h-11"
|
||||
autoFocus
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
|
||||
{searchTerm.length < 2 ? (
|
||||
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Search className="h-8 w-8 opacity-20" />
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
"ui.admin.tenants.admins.dialog_search_hint",
|
||||
"검색어를 입력해 주세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : usersQuery.isLoading ? (
|
||||
<div className="p-10 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-10 text-center text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.admins.dialog_no_results",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{searchResults.map((user) => {
|
||||
const isAlreadyInMatrix = relations.some(
|
||||
(r) => r.userId === user.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{user.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isAlreadyInMatrix ? "ghost" : "outline"}
|
||||
disabled={
|
||||
isAlreadyInMatrix || addRelationMutation.isPending
|
||||
}
|
||||
onClick={() => handleAddUser(user.id)}
|
||||
>
|
||||
{isAlreadyInMatrix ? (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{t(
|
||||
"ui.admin.tenants.relations.already_added",
|
||||
"이미 추가됨",
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3 w-3 mr-1" />{" "}
|
||||
{t("ui.common.add", "추가")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
removeGroupMember,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
type UserGroupNode = GroupSummary & {
|
||||
children: UserGroupNode[];
|
||||
@@ -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,11 @@ function TenantGroupsPage() {
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const _queryClient = useQueryClient();
|
||||
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable =
|
||||
hasPermission("manage_organization") || hasPermission("manage");
|
||||
const canView = hasPermission("view_organization") || hasPermission("view");
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
|
||||
@@ -387,6 +398,16 @@ function TenantGroupsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||
<h3 className="text-xl font-bold text-destructive">
|
||||
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groupTree = groupsQuery.data
|
||||
? buildGroupTree(groupsQuery.data, tenantId)
|
||||
: [];
|
||||
@@ -423,6 +444,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 +459,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 +472,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 +493,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 +503,9 @@ 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 +596,7 @@ function TenantGroupsPage() {
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
isWritable={isWritable}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "../../../../../common/core/utils";
|
||||
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -378,6 +377,9 @@ function TenantListPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const isWritable =
|
||||
profileRole === "super_admin" ||
|
||||
!!profile?.systemPermissions?.manage_tenants;
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
|
||||
@@ -582,7 +584,11 @@ function TenantListPage() {
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [allTenants, scopePickerOpen]);
|
||||
|
||||
if (profile && profileRole !== "super_admin") {
|
||||
if (
|
||||
profile &&
|
||||
profileRole !== "super_admin" &&
|
||||
!profile?.systemPermissions?.tenants
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<h3 className="text-lg font-bold">
|
||||
@@ -841,81 +847,83 @@ function TenantListPage() {
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
name="tenant-import-file"
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
data-testid="tenant-import-input"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="tenant-data-mgmt-btn"
|
||||
className="gap-2 h-9"
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleTemplateDownload}
|
||||
data-testid="tenant-template-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileSpreadsheet
|
||||
size={16}
|
||||
className="mr-2 opacity-50"
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.tenants.csv_template",
|
||||
"템플릿 다운로드",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Upload size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.admin.tenants.import", "CSV 가져오기")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_without_ids",
|
||||
"UUID 제외 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-with-ids-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_with_ids",
|
||||
"UUID 포함 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</RoleGuard>
|
||||
{isWritable && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
name="tenant-import-file"
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
data-testid="tenant-import-input"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="tenant-data-mgmt-btn"
|
||||
className="gap-2 h-9"
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleTemplateDownload}
|
||||
data-testid="tenant-template-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileSpreadsheet
|
||||
size={16}
|
||||
className="mr-2 opacity-50"
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.tenants.csv_template",
|
||||
"템플릿 다운로드",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Upload size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.admin.tenants.import", "CSV 가져오기")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_without_ids",
|
||||
"UUID 제외 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-with-ids-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_with_ids",
|
||||
"UUID 포함 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -929,14 +937,14 @@ function TenantListPage() {
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</span>
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{isWritable && (
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -1072,7 +1080,7 @@ function TenantListPage() {
|
||||
{t("ui.common.apply", "적용")}
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{isWritable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1084,7 +1092,7 @@ function TenantListPage() {
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -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,10 @@ export function TenantProfilePage() {
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable = hasPermission("manage_profile") || hasPermission("manage");
|
||||
const canView = hasPermission("view_profile") || hasPermission("view");
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
@@ -203,6 +208,16 @@ export function TenantProfilePage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||
<h3 className="text-xl font-bold text-destructive">
|
||||
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (isProtectedSeedTenant) {
|
||||
return;
|
||||
@@ -261,13 +276,21 @@ 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 +306,7 @@ export function TenantProfilePage() {
|
||||
excludeTenantId={tenantId}
|
||||
compact
|
||||
controlTestId="tenant-parent-picker-control"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,6 +324,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 +371,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 +391,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 +419,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 +452,7 @@ export function TenantProfilePage() {
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -442,6 +471,7 @@ export function TenantProfilePage() {
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder="example.com, example.kr"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -454,6 +484,7 @@ export function TenantProfilePage() {
|
||||
size="sm"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
@@ -462,6 +493,7 @@ export function TenantProfilePage() {
|
||||
size="sm"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
@@ -480,7 +512,9 @@ export function TenantProfilePage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending || isProtectedSeedTenant}
|
||||
disabled={
|
||||
deleteMutation.isPending || isProtectedSeedTenant || !isWritable
|
||||
}
|
||||
title={
|
||||
isProtectedSeedTenant
|
||||
? t(
|
||||
@@ -499,7 +533,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 +546,8 @@ export function TenantProfilePage() {
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
tenantQuery.isLoading ||
|
||||
name.trim() === ""
|
||||
name.trim() === "" ||
|
||||
!isWritable
|
||||
}
|
||||
>
|
||||
<Save size={16} />
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
import {
|
||||
createSchemaField,
|
||||
isSchemaFieldType,
|
||||
@@ -28,13 +28,11 @@ export function TenantSchemaPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccess = profileRole === "super_admin";
|
||||
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(
|
||||
tenantId ?? "",
|
||||
);
|
||||
const canView = hasPermission("view_schema") || hasPermission("view");
|
||||
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
@@ -42,7 +40,7 @@ export function TenantSchemaPage() {
|
||||
if (!tenantId) throw new Error("Tenant ID is required");
|
||||
return fetchTenant(tenantId);
|
||||
},
|
||||
enabled: !!tenantId && canAccess,
|
||||
enabled: !!tenantId && canView,
|
||||
});
|
||||
|
||||
const [fields, setFields] = useState<SchemaField[]>([]);
|
||||
@@ -85,7 +83,7 @@ export function TenantSchemaPage() {
|
||||
},
|
||||
});
|
||||
|
||||
if (isProfileLoading) {
|
||||
if (isPermissionLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center animate-pulse text-muted-foreground">
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
@@ -93,7 +91,7 @@ export function TenantSchemaPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccess) {
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
|
||||
<h3 className="text-xl font-bold text-destructive">
|
||||
@@ -147,7 +145,7 @@ export function TenantSchemaPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={addField} size="sm">
|
||||
<Button onClick={addField} size="sm" disabled={!isWritable}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
|
||||
</Button>
|
||||
@@ -182,6 +180,7 @@ export function TenantSchemaPage() {
|
||||
"예: employee_id",
|
||||
)}
|
||||
className="h-10"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -198,6 +197,7 @@ export function TenantSchemaPage() {
|
||||
"예: 사번",
|
||||
)}
|
||||
className="h-10"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -207,8 +207,9 @@ export function TenantSchemaPage() {
|
||||
<select
|
||||
id={`tenant-schema-field-type-${field.key || index}`}
|
||||
name={`tenant-schema-field-type-${field.key || index}`}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary disabled:opacity-60"
|
||||
value={field.type}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
if (isSchemaFieldType(nextType)) {
|
||||
@@ -271,10 +272,11 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-required-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, { required: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
|
||||
@@ -285,10 +287,11 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-admin-only-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.adminOnly}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, { adminOnly: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
@@ -302,6 +305,7 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-login-id-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.isLoginId || false}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
isLoginId: e.target.checked,
|
||||
@@ -309,7 +313,7 @@ export function TenantSchemaPage() {
|
||||
type: e.target.checked ? "text" : field.type,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{t(
|
||||
@@ -323,7 +327,7 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-indexed-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.indexed || field.isLoginId || false}
|
||||
disabled={field.isLoginId}
|
||||
disabled={field.isLoginId || !isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, { indexed: e.target.checked })
|
||||
}
|
||||
@@ -342,10 +346,11 @@ export function TenantSchemaPage() {
|
||||
name={`tenant-schema-field-unsigned-${field.key || index}`}
|
||||
type="checkbox"
|
||||
checked={field.unsigned}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, { unsigned: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
@@ -359,6 +364,7 @@ export function TenantSchemaPage() {
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={field.validation}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
updateField(index, { validation: e.target.value })
|
||||
}
|
||||
@@ -375,6 +381,7 @@ export function TenantSchemaPage() {
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-10 w-10"
|
||||
onClick={() => removeField(index)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</Button>
|
||||
@@ -388,7 +395,9 @@ export function TenantSchemaPage() {
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={() => updateMutation.mutate(fields)}
|
||||
disabled={updateMutation.isPending || tenantQuery.isLoading}
|
||||
disabled={
|
||||
updateMutation.isPending || tenantQuery.isLoading || !isWritable
|
||||
}
|
||||
className="px-8 h-11"
|
||||
>
|
||||
<Save size={18} className="mr-2" />
|
||||
|
||||
@@ -159,7 +159,9 @@ function UserCreatePage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canManageUsers = canManageTenantScopedUsers(profile);
|
||||
const canManageUsers =
|
||||
canManageTenantScopedUsers(profile) ||
|
||||
!!profile?.systemPermissions?.manage_users;
|
||||
|
||||
const {
|
||||
register,
|
||||
|
||||
@@ -654,6 +654,17 @@ function UserDetailPage() {
|
||||
const isAdmin = profileRole === "super_admin";
|
||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
|
||||
const isWritable =
|
||||
isAdmin ||
|
||||
isSelf ||
|
||||
canManageCurrentUser ||
|
||||
!!profile?.systemPermissions?.manage_users;
|
||||
const canViewUser =
|
||||
isAdmin ||
|
||||
isSelf ||
|
||||
canManageCurrentUser ||
|
||||
!!profile?.systemPermissions?.users ||
|
||||
!!profile?.systemPermissions?.manage_users;
|
||||
const watchedStatus = watch("status");
|
||||
|
||||
const [newSubEmail, setNewSubEmail] = React.useState("");
|
||||
@@ -1235,7 +1246,7 @@ function UserDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin && !isSelf && !canManageCurrentUser) {
|
||||
if (profile && !canViewUser) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<ShieldAlert size={48} className="text-destructive" />
|
||||
@@ -1944,22 +1955,24 @@ function UserDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<span className="text-base font-bold">
|
||||
{t("ui.admin.users.detail.save", "저장하기")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{isWritable && (
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<span className="text-base font-bold">
|
||||
{t("ui.admin.users.detail.save", "저장하기")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
|
||||
@@ -382,6 +382,8 @@ function UserListPage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const isWritable =
|
||||
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", "all"],
|
||||
@@ -796,8 +798,9 @@ function UserListPage() {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setBulkUploadOpen(true);
|
||||
if (isWritable) setBulkUploadOpen(true);
|
||||
}}
|
||||
disabled={!isWritable}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Upload size={16} className="mr-2 opacity-50" />
|
||||
@@ -889,12 +892,19 @@ function UserListPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/users/new">
|
||||
{isWritable ? (
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/users/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="h-9" disabled>
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -1172,7 +1182,8 @@ function UserListPage() {
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
user.id === profile?.id ||
|
||||
!isWritable
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -1341,7 +1352,8 @@ function UserListPage() {
|
||||
}}
|
||||
disabled={
|
||||
(!selectedBulkStatus && !selectedBulkPermission) ||
|
||||
bulkUpdateMutation.isPending
|
||||
bulkUpdateMutation.isPending ||
|
||||
!isWritable
|
||||
}
|
||||
data-testid="bulk-apply-btn"
|
||||
>
|
||||
@@ -1354,6 +1366,7 @@ function UserListPage() {
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={!isWritable}
|
||||
data-testid="bulk-delete-btn"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -33,6 +33,19 @@ export type TenantSummary = {
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number; // 해당 테넌트 직접 소속 인원
|
||||
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
|
||||
userPermissions?: {
|
||||
view: boolean;
|
||||
manage: boolean;
|
||||
manage_admins: boolean;
|
||||
view_profile?: boolean;
|
||||
manage_profile?: boolean;
|
||||
view_permissions?: boolean;
|
||||
manage_permissions?: boolean;
|
||||
view_organization?: boolean;
|
||||
manage_organization?: boolean;
|
||||
view_schema?: boolean;
|
||||
manage_schema?: boolean;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -469,6 +482,61 @@ export async function removeTenantOwner(tenantId: string, userId: string) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||
}
|
||||
|
||||
export type TenantRelation = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
relations: string[];
|
||||
};
|
||||
|
||||
export async function fetchTenantRelations(tenantId: string) {
|
||||
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
|
||||
`/v1/admin/tenants/${tenantId}/relations`,
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function addTenantRelation(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
relation: string,
|
||||
) {
|
||||
await apiClient.post(`/v1/admin/tenants/${tenantId}/relations`, {
|
||||
userId,
|
||||
relation,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTenantRelation(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
relation: string,
|
||||
) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/relations`, {
|
||||
data: { userId, relation },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSystemRelations() {
|
||||
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
|
||||
`/v1/admin/system/relations`,
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function addSystemRelation(userId: string, relation: string) {
|
||||
await apiClient.post(`/v1/admin/system/relations`, {
|
||||
userId,
|
||||
relation,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeSystemRelation(userId: string, relation: string) {
|
||||
await apiClient.delete(`/v1/admin/system/relations`, {
|
||||
data: { userId, relation },
|
||||
});
|
||||
}
|
||||
|
||||
// Group Management
|
||||
export type GroupMember = {
|
||||
id: string;
|
||||
@@ -1192,6 +1260,32 @@ export async function fetchUserRpHistory(userId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export type SystemPermissions = {
|
||||
overview: boolean;
|
||||
tenants: boolean;
|
||||
org_chart: boolean;
|
||||
worksmobile: boolean;
|
||||
ory_ssot: boolean;
|
||||
data_integrity: boolean;
|
||||
users: boolean;
|
||||
permissions_direct: boolean;
|
||||
auth_guard: boolean;
|
||||
api_keys: boolean;
|
||||
audit_logs: boolean;
|
||||
|
||||
manage_overview?: boolean;
|
||||
manage_tenants?: boolean;
|
||||
manage_org_chart?: boolean;
|
||||
manage_worksmobile?: boolean;
|
||||
manage_ory_ssot?: boolean;
|
||||
manage_data_integrity?: boolean;
|
||||
manage_users?: boolean;
|
||||
manage_permissions_direct?: boolean;
|
||||
manage_auth_guard?: boolean;
|
||||
manage_api_keys?: boolean;
|
||||
manage_audit_logs?: boolean;
|
||||
};
|
||||
|
||||
export type UserProfileResponse = {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -1205,6 +1299,7 @@ export type UserProfileResponse = {
|
||||
metadata?: Record<string, unknown>;
|
||||
tenant?: TenantSummary;
|
||||
manageableTenants?: TenantSummary[];
|
||||
systemPermissions?: SystemPermissions;
|
||||
};
|
||||
|
||||
export async function fetchMe() {
|
||||
|
||||
@@ -972,6 +972,7 @@ org_chart = "Org Chart"
|
||||
api_keys = "API Keys"
|
||||
audit_logs = "Audit Logs"
|
||||
auth_guard = "Auth Guard"
|
||||
permissions_direct = "Direct Permissions"
|
||||
data_integrity = "Data Integrity"
|
||||
logout = "Logout"
|
||||
overview = "Overview"
|
||||
@@ -996,10 +997,6 @@ title = "Redis identity cache"
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = "Access denied"
|
||||
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = "PostgreSQL read model status used by admin search and statistics."
|
||||
title = "Backend user read model"
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = "failed"
|
||||
not_ready = "not ready"
|
||||
@@ -1008,11 +1005,8 @@ ready = "ready"
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = "Cache keys"
|
||||
last_refreshed = "Last refreshed"
|
||||
last_synced = "Last read-model refresh"
|
||||
local_users = "Local users"
|
||||
observed_identities = "Observed identities"
|
||||
status = "Status"
|
||||
updated_at = "Updated at"
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
|
||||
@@ -1184,6 +1178,7 @@ tab_organization = "Organization Manage"
|
||||
tab_permissions = "Permissions"
|
||||
tab_profile = "Profile"
|
||||
tab_schema = "Tab Schema"
|
||||
tab_relations = "Fine-grained Permissions"
|
||||
title = "Details"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
@@ -1494,7 +1489,6 @@ email = "Email"
|
||||
name = "Name"
|
||||
role = "Role"
|
||||
|
||||
|
||||
[ui.common.role]
|
||||
admin = "Admin"
|
||||
rp_admin = "RP Admin"
|
||||
@@ -2006,3 +2000,24 @@ verify = "Verify"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Action"
|
||||
|
||||
[ui.admin.permissions_direct]
|
||||
tab_tenant = "Tenant Features"
|
||||
tab_system = "Admin Control"
|
||||
tab_system_title = "Global Sidebar Access Control"
|
||||
select_tenant = "Select target tenant"
|
||||
select_tenant_desc = "Select target tenant to assign fine-grained permissions."
|
||||
placeholder = "-- Select Tenant --"
|
||||
add_system_user = "Add User to Admin Control"
|
||||
dialog_title_system = "Add User to Global Permissions"
|
||||
|
||||
[msg.admin.permissions_direct]
|
||||
description = "Directly assign and manage tab-level direct permissions and global sidebar menu access."
|
||||
tab_system_desc = "Directly grant users access to each sidebar menu page. Super admins always bypass and pass all access checks."
|
||||
system_empty = "No users with custom global menu permissions found. Add users to start managing."
|
||||
select_prompt = "Select a tenant from the dropdown above to manage its fine-grained features."
|
||||
|
||||
[msg.admin.system.relations]
|
||||
add_success = "Global menu permission added successfully."
|
||||
remove_success = "Global menu permission revoked successfully."
|
||||
remove_all_confirm = "Are you sure you want to revoke all global menu permissions for this user?"
|
||||
|
||||
@@ -976,6 +976,7 @@ org_chart = "조직도"
|
||||
api_keys = "API 키"
|
||||
audit_logs = "감사 로그"
|
||||
auth_guard = "인증 가드"
|
||||
permissions_direct = "권한 부여"
|
||||
data_integrity = "데이터 정합성"
|
||||
logout = "로그아웃"
|
||||
overview = "개요"
|
||||
@@ -1000,10 +1001,6 @@ title = "Redis identity cache"
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = "접근 권한이 없습니다"
|
||||
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다."
|
||||
title = "Backend 사용자 read model"
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = "실패"
|
||||
not_ready = "준비되지 않음"
|
||||
@@ -1012,11 +1009,8 @@ ready = "준비됨"
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = "Cache keys"
|
||||
last_refreshed = "마지막 refresh"
|
||||
last_synced = "마지막 read-model refresh"
|
||||
local_users = "Local users"
|
||||
observed_identities = "관측 identity"
|
||||
status = "상태"
|
||||
updated_at = "상태 갱신"
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
|
||||
@@ -1188,6 +1182,7 @@ tab_organization = "조직 관리"
|
||||
tab_permissions = "권한"
|
||||
tab_profile = "프로필"
|
||||
tab_schema = "사용자 스키마"
|
||||
tab_relations = "세부 권한"
|
||||
title = "상세"
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
@@ -1498,7 +1493,6 @@ email = "이메일"
|
||||
name = "이름"
|
||||
role = "역할"
|
||||
|
||||
|
||||
[ui.common.role]
|
||||
admin = "Admin"
|
||||
rp_admin = "RP Admin"
|
||||
@@ -2006,3 +2000,24 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
[ui.admin.permissions_direct]
|
||||
tab_tenant = "테넌트 기능 권한"
|
||||
tab_system = "시스템 메뉴 권한 (Admin Control)"
|
||||
tab_system_title = "글로벌 메뉴 접근 제어 (Admin Control)"
|
||||
select_tenant = "대상 테넌트 선택"
|
||||
select_tenant_desc = "세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요."
|
||||
placeholder = "-- 테넌트 선택 --"
|
||||
add_system_user = "시스템 권한 사용자 추가"
|
||||
dialog_title_system = "시스템 권한 관리 유저 추가"
|
||||
|
||||
[msg.admin.permissions_direct]
|
||||
description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다."
|
||||
tab_system_desc = "사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다."
|
||||
system_empty = "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요."
|
||||
select_prompt = "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다."
|
||||
|
||||
[msg.admin.system.relations]
|
||||
add_success = "시스템 메뉴 권한이 추가되었습니다."
|
||||
remove_success = "시스템 메뉴 권한이 회수되었습니다."
|
||||
remove_all_confirm = "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"
|
||||
|
||||
@@ -193,7 +193,6 @@ worksmobile_excluded = ""
|
||||
worksmobile_sync = ""
|
||||
allowed_domains = ""
|
||||
|
||||
|
||||
[msg.admin.ory_ssot]
|
||||
flush_confirm = ""
|
||||
flush_error = ""
|
||||
@@ -981,6 +980,7 @@ org_chart = ""
|
||||
api_keys = ""
|
||||
audit_logs = ""
|
||||
auth_guard = ""
|
||||
permissions_direct = ""
|
||||
data_integrity = ""
|
||||
logout = ""
|
||||
overview = ""
|
||||
@@ -1005,10 +1005,6 @@ title = ""
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = ""
|
||||
not_ready = ""
|
||||
@@ -1017,11 +1013,8 @@ ready = ""
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = ""
|
||||
last_refreshed = ""
|
||||
last_synced = ""
|
||||
local_users = ""
|
||||
observed_identities = ""
|
||||
status = ""
|
||||
updated_at = ""
|
||||
|
||||
[ui.admin.auth_guard]
|
||||
subtitle = ""
|
||||
@@ -1193,6 +1186,7 @@ tab_organization = ""
|
||||
tab_permissions = ""
|
||||
tab_profile = ""
|
||||
tab_schema = ""
|
||||
tab_relations = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
@@ -1451,7 +1445,6 @@ email = ""
|
||||
name = ""
|
||||
role = ""
|
||||
|
||||
|
||||
[ui.common.role]
|
||||
admin = ""
|
||||
rp_admin = ""
|
||||
@@ -1961,3 +1954,24 @@ verify = ""
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = ""
|
||||
|
||||
[ui.admin.permissions_direct]
|
||||
tab_tenant = ""
|
||||
tab_system = ""
|
||||
tab_system_title = ""
|
||||
select_tenant = ""
|
||||
select_tenant_desc = ""
|
||||
placeholder = ""
|
||||
add_system_user = ""
|
||||
dialog_title_system = ""
|
||||
|
||||
[msg.admin.permissions_direct]
|
||||
description = ""
|
||||
tab_system_desc = ""
|
||||
system_empty = ""
|
||||
select_prompt = ""
|
||||
|
||||
[msg.admin.system.relations]
|
||||
add_success = ""
|
||||
remove_success = ""
|
||||
remove_all_confirm = ""
|
||||
|
||||
Reference in New Issue
Block a user