1
0
forked from baron/baron-sso

Merge origin/dev into dev

This commit is contained in:
2026-06-15 20:05:47 +09:00
67 changed files with 6933 additions and 3919 deletions

View File

@@ -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 />,
},
],
},
{

View File

@@ -116,6 +116,7 @@ describe("admin AppLayout", () => {
"Ory SSOT System",
"Data Integrity",
"Users",
"권한 부여",
"Auth Guard",
"API Keys",
"Audit Logs",

View File

@@ -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 = () => {

View File

@@ -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: [

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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}</>;
}

View File

@@ -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();
});
});

View 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 };
}

View File

@@ -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", "관리자 추가")}

View File

@@ -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 */}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -25,6 +25,7 @@ import {
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import { useTenantPermission } from "../hooks/useTenantPermission";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
@@ -52,6 +53,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} />

View File

@@ -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" />

View File

@@ -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,

View File

@@ -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

View File

@@ -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} />

View File

@@ -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() {

View File

@@ -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?"

View File

@@ -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 = "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"

View File

@@ -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 = ""