1
0
forked from baron/baron-sso

adminfront 및 백엔드: 글로벌 사이드바 11개 전 메뉴별 ReBAC 기반 접근 제어(Admin Control) 스키마, REST API, UI 설정 패널 전격 구현 완료

This commit is contained in:
2026-06-10 16:55:34 +09:00
parent 5b4efae001
commit b4f80a36b0
12 changed files with 976 additions and 113 deletions

View File

@@ -212,71 +212,70 @@ function AppLayout() {
...profile,
role: effectiveRole ?? profile?.role,
});
const filteredItems = items.filter((item) => {
if (item.to === "/api-keys") return isSuperAdmin;
if (item.to === "/permissions-direct") return isSuperAdmin || _manageableCount > 0;
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 permissions.permissions_direct || _manageableCount > 0;
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

@@ -1,9 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useState } from "react";
import { fetchAllTenants, fetchMe } from "../../../lib/adminApi";
import {
fetchAllTenants,
fetchMe,
fetchUsers,
fetchSystemRelations,
addSystemRelation,
removeSystemRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab";
import { ShieldCheck } from "lucide-react";
import { ShieldCheck, Search, Plus, UserPlus, Trash2, Settings, Shield } from "lucide-react";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
@@ -11,9 +22,30 @@ import {
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";
export function TenantFineGrainedPermissionsPage() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"tenant" | "system">("tenant");
const [selectedTenantId, setSelectedTenantId] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -32,6 +64,87 @@ export function TenantFineGrainedPermissionsPage() {
? (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 ?? [];
const addSystemRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
addSystemRelation(payload.userId, payload.relation),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
toast.success(t("msg.admin.system.relations.add_success", "시스템 메뉴 권한이 추가되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
},
});
const removeSystemRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
removeSystemRelation(payload.userId, payload.relation),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
toast.success(t("msg.admin.system.relations.remove_success", "시스템 메뉴 권한이 회수되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
},
});
const handleSystemRelationChange = async (
userId: string,
relation: string,
hasAccess: boolean,
) => {
if (hasAccess) {
await addSystemRelationMutation.mutateAsync({ userId, relation });
} else {
await removeSystemRelationMutation.mutateAsync({ userId, relation });
}
};
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 });
}
};
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" });
setIsDialogOpen(false);
setSearchTerm("");
};
const searchResults = usersQuery.data?.items || [];
const systemMenus = [
{ label: t("ui.admin.nav.overview", "개요"), relation: "overview_viewers" },
{ label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants_viewers" },
{ label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart_viewers" },
{ label: t("ui.admin.nav.worksmobile", "Worksmobile"), relation: "worksmobile_viewers" },
{ label: t("ui.admin.nav.ory_ssot", "Ory SSOT"), relation: "ory_ssot_viewers" },
{ label: t("ui.admin.nav.data_integrity", "정합성"), relation: "data_integrity_viewers" },
{ label: t("ui.admin.nav.users", "사용자"), relation: "users_viewers" },
{ label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct_viewers" },
{ label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard_viewers" },
{ label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys_viewers" },
{ label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs_viewers" },
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
@@ -42,46 +155,290 @@ export function TenantFineGrainedPermissionsPage() {
<p className="text-muted-foreground">
{t(
"msg.admin.permissions_direct.description",
"선택한 테넌트의 기능별 세부 조회 및 수정 권한을 지정하고 부여합니다.",
"테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.",
)}
</p>
</div>
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="pb-4">
<CardTitle className="text-lg font-semibold">
{t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.permissions_direct.select_tenant_desc",
"권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<select
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
className="flex h-10 w-full max-w-[360px] 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"
{/* Tab Selectors */}
{isSuperAdmin && (
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("tenant")}
className={`px-6 py-3 text-sm font-semibold transition-colors relative ${
activeTab === "tenant"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<option value="">{t("ui.admin.permissions_direct.placeholder", "-- 테넌트 선택 --")}</option>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug})
</option>
))}
</select>
</CardContent>
</Card>
{selectedTenantId ? (
<TenantFineGrainedPermissionsTab tenantIdProp={selectedTenantId} />
) : (
<div className="rounded-lg border border-dashed border-border p-12 text-center text-muted-foreground">
{t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")}
<Settings className="inline-block h-4 w-4 mr-2" />
{t("ui.admin.permissions_direct.tab_tenant", "테넌트 기능 권한")}
</button>
<button
onClick={() => setActiveTab("system")}
className={`px-6 py-3 text-sm font-semibold transition-colors relative ${
activeTab === "system"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Shield className="inline-block h-4 w-4 mr-2" />
{t("ui.admin.permissions_direct.tab_system", "시스템 메뉴 권한 (Admin Control)")}
</button>
</div>
)}
{activeTab === "tenant" ? (
<>
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="pb-4">
<CardTitle className="text-lg font-semibold">
{t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.permissions_direct.select_tenant_desc",
"세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<select
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
className="flex h-10 w-full max-w-[360px] 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="">{t("ui.admin.permissions_direct.placeholder", "-- 테넌트 선택 --")}</option>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug})
</option>
))}
</select>
</CardContent>
</Card>
{selectedTenantId ? (
<TenantFineGrainedPermissionsTab tenantIdProp={selectedTenantId} />
) : (
<div className="rounded-lg border border-dashed border-border p-12 text-center text-muted-foreground">
{t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")}
</div>
)}
</>
) : (
/* 시스템 메뉴 권한 (Admin Control) Tab Panel */
<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.permissions_direct.tab_system_title", "글로벌 메뉴 접근 제어 (Admin Control)")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.permissions_direct.tab_system_desc",
"사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsDialogOpen(true)}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.permissions_direct.add_system_user", "시스템 권한 사용자 추가")}
</Button>
</CardHeader>
<CardContent className="pt-0">
<div className="rounded-md border border-border overflow-hidden overflow-x-auto">
<Table className="min-w-[1200px]">
<TableHeader className="bg-secondary/40">
<TableRow>
<TableHead className="font-bold sticky left-0 bg-background z-20 w-[180px]">{t("ui.common.name", "이름")}</TableHead>
{systemMenus.map((menu) => (
<TableHead key={menu.relation} className="font-bold text-center text-xs">
{menu.label}
</TableHead>
))}
<TableHead className="font-bold text-center w-[70px]">{t("ui.common.action", "작업")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{systemRelations.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="text-center py-12 text-muted-foreground">
{t("msg.admin.permissions_direct.system_empty", "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요.")}
</TableCell>
</TableRow>
) : (
systemRelations.map((user) => {
return (
<TableRow key={user.userId} className="hover:bg-muted/10 transition-colors">
<TableCell className="font-medium sticky left-0 bg-background z-10 shadow-[2px_0_5px_rgba(0,0,0,0.05)]">
<div className="flex flex-col">
<span className="font-semibold text-foreground">{user.name}</span>
<span className="text-[10px] text-muted-foreground italic truncate max-w-[150px]">{user.email}</span>
</div>
</TableCell>
{systemMenus.map((menu) => {
const hasAccess = user.relations.includes(menu.relation);
return (
<TableCell key={menu.relation} className="text-center">
<select
className="mx-auto flex h-8 w-[100px] rounded-md border border-input bg-background px-1.5 py-0.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={hasAccess ? "access" : "none"}
onChange={(e) =>
handleSystemRelationChange(
user.userId,
menu.relation,
e.target.value === "access",
)
}
>
<option value="none">{t("ui.common.none", "X")}</option>
<option value="access">{t("ui.common.access", "허용")}</option>
</select>
</TableCell>
);
})}
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveAllSystemRelations(user.userId, user.relations)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* 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}
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

@@ -526,6 +526,32 @@ export async function removeTenantRelation(
});
}
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;
@@ -1237,6 +1263,20 @@ 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;
};
export type UserProfileResponse = {
id: string;
email: string;
@@ -1250,6 +1290,7 @@ export type UserProfileResponse = {
metadata?: Record<string, unknown>;
tenant?: TenantSummary;
manageableTenants?: TenantSummary[];
systemPermissions?: SystemPermissions;
};
export async function fetchMe() {

View File

@@ -2002,3 +2002,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

@@ -2002,3 +2002,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

@@ -1961,3 +1961,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 = ""