forked from baron/baron-sso
Merge remote-tracking branch 'origin/dev' into dev
# Conflicts: # adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx # adminfront/tests/worksmobile.spec.ts
This commit is contained in:
@@ -270,8 +270,7 @@ function AppLayout() {
|
||||
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 === "/worksmobile") return permissions.worksmobile;
|
||||
if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
|
||||
if (item.to === "/system/data-integrity")
|
||||
return permissions.data_integrity;
|
||||
|
||||
@@ -61,7 +61,7 @@ const users = [
|
||||
id: "user-owner",
|
||||
name: "Owner User",
|
||||
email: "owner@example.com",
|
||||
role: "tenant_admin",
|
||||
role: "super_admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,7 +13,9 @@ export type TenantPermissionKey =
|
||||
| "view_organization"
|
||||
| "manage_organization"
|
||||
| "view_schema"
|
||||
| "manage_schema";
|
||||
| "manage_schema"
|
||||
| "view_worksmobile"
|
||||
| "manage_worksmobile";
|
||||
|
||||
export function useTenantPermission(tenantId: string) {
|
||||
const { data: profile } = useQuery({
|
||||
|
||||
@@ -537,14 +537,18 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
name: selection.name,
|
||||
email: selection.email,
|
||||
tenantSlug: selection.leafTenantName,
|
||||
tenant: selection.leafTenantName
|
||||
? {
|
||||
id: "",
|
||||
slug: "",
|
||||
name: selection.leafTenantName,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
}
|
||||
tenant: selection.leafTenantName
|
||||
? {
|
||||
id: "",
|
||||
type: "ORGANIZATION",
|
||||
slug: "",
|
||||
name: selection.leafTenantName,
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
}
|
||||
: undefined,
|
||||
metadata: {
|
||||
rootTenantName: selection.rootTenantName,
|
||||
@@ -985,37 +989,51 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{queuedTargetUsers.map((user) => (
|
||||
<span
|
||||
key={user.id}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
|
||||
>
|
||||
<span className="max-w-52 truncate">
|
||||
{user.name}
|
||||
</span>
|
||||
{(user.metadata?.rootTenantName ||
|
||||
user.metadata?.leafTenantName) && (
|
||||
<span className="max-w-64 truncate text-xs text-muted-foreground">
|
||||
{[user.metadata?.rootTenantName, user.metadata?.leafTenantName]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => removeQueuedTargetUser(user.id)}
|
||||
aria-label={t(
|
||||
"ui.admin.permissions_direct.target_queue_remove",
|
||||
"적용 대상에서 제거",
|
||||
)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{queuedTargetUsers.map((user) => {
|
||||
const rootTenantName =
|
||||
typeof user.metadata?.rootTenantName === "string"
|
||||
? user.metadata.rootTenantName
|
||||
: "";
|
||||
const leafTenantName =
|
||||
typeof user.metadata?.leafTenantName === "string"
|
||||
? user.metadata.leafTenantName
|
||||
: "";
|
||||
const tenantPath = [
|
||||
rootTenantName,
|
||||
leafTenantName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
return (
|
||||
<span
|
||||
key={user.id}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
|
||||
>
|
||||
<span className="max-w-52 truncate">
|
||||
{user.name}
|
||||
</span>
|
||||
{tenantPath !== "" && (
|
||||
<span className="max-w-64 truncate text-xs text-muted-foreground">
|
||||
{tenantPath}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => removeQueuedTargetUser(user.id)}
|
||||
aria-label={t(
|
||||
"ui.admin.permissions_direct.target_queue_remove",
|
||||
"적용 대상에서 제거",
|
||||
)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1410,258 +1428,6 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{false && (
|
||||
<>
|
||||
{/* 시스템 메뉴 권한 (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>
|
||||
</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>
|
||||
{protectedSystemMenuRelations.has(
|
||||
menu.relation,
|
||||
) && (
|
||||
<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={protectedSystemMenuRelations.has(
|
||||
menu.relation,
|
||||
)}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ import {
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addTenantRelation,
|
||||
fetchMe,
|
||||
fetchTenantRelations,
|
||||
fetchUsers,
|
||||
removeTenantRelation,
|
||||
type TenantRelation,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
interface TenantFineGrainedPermissionsTabProps {
|
||||
tenantIdProp?: string;
|
||||
@@ -48,8 +48,11 @@ export function TenantFineGrainedPermissionsTab({
|
||||
}: TenantFineGrainedPermissionsTabProps = {}) {
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdProp || tenantIdParam || "";
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable = hasPermission("manage_admins");
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const isWritable = profile?.role === "super_admin";
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
@@ -75,7 +78,13 @@ export function TenantFineGrainedPermissionsTab({
|
||||
> = {};
|
||||
for (const user of relationsQuery.data) {
|
||||
initialMap[user.userId] = {};
|
||||
const tabs = ["profile", "permissions", "organization", "schema"];
|
||||
const tabs = [
|
||||
"profile",
|
||||
"permissions",
|
||||
"organization",
|
||||
"schema",
|
||||
"worksmobile",
|
||||
];
|
||||
for (const tab of tabs) {
|
||||
const isWrite = user.relations.includes(`${tab}_managers`);
|
||||
const isRead = user.relations.includes(`${tab}_viewers`);
|
||||
@@ -204,7 +213,7 @@ export function TenantFineGrainedPermissionsTab({
|
||||
|
||||
const handleRelationChange = async (
|
||||
userId: string,
|
||||
tab: "profile" | "permissions" | "organization" | "schema",
|
||||
tab: "profile" | "permissions" | "organization" | "schema" | "worksmobile",
|
||||
currentVal: "none" | "read" | "write",
|
||||
newVal: "none" | "read" | "write",
|
||||
) => {
|
||||
@@ -318,6 +327,14 @@ export function TenantFineGrainedPermissionsTab({
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{!isWritable && (
|
||||
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-950/20 text-amber-800 dark:text-amber-200 border border-amber-200 dark:border-amber-800/30 rounded-lg text-sm font-medium">
|
||||
{t(
|
||||
"msg.admin.tenants.relations.super_admin_only_desc",
|
||||
"이 화면의 권한 설정은 시스템 최고 관리자(super_admin)만 수정할 수 있습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-secondary/40">
|
||||
@@ -337,6 +354,12 @@ export function TenantFineGrainedPermissionsTab({
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t(
|
||||
"ui.admin.tenants.detail.tab_worksmobile",
|
||||
"네이버웍스 연동",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold text-center w-20">
|
||||
{t("ui.common.action", "작업")}
|
||||
</TableHead>
|
||||
@@ -346,7 +369,7 @@ export function TenantFineGrainedPermissionsTab({
|
||||
{relations.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
className="text-center py-12 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -387,6 +410,14 @@ export function TenantFineGrainedPermissionsTab({
|
||||
? "read"
|
||||
: "none";
|
||||
|
||||
const worksmobileVal = user.relations.includes(
|
||||
"worksmobile_managers",
|
||||
)
|
||||
? "write"
|
||||
: user.relations.includes("worksmobile_viewers")
|
||||
? "read"
|
||||
: "none";
|
||||
|
||||
const curProfileVal =
|
||||
localTenantPermissions[user.userId]?.profile ??
|
||||
profileVal;
|
||||
@@ -398,6 +429,9 @@ export function TenantFineGrainedPermissionsTab({
|
||||
organizationVal;
|
||||
const curSchemaVal =
|
||||
localTenantPermissions[user.userId]?.schema ?? schemaVal;
|
||||
const curWorksmobileVal =
|
||||
localTenantPermissions[user.userId]?.worksmobile ??
|
||||
worksmobileVal;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -562,6 +596,43 @@ export function TenantFineGrainedPermissionsTab({
|
||||
</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={curWorksmobileVal}
|
||||
disabled={!isWritable}
|
||||
name={`tenant-fine-grained-worksmobile-${user.userId}`}
|
||||
onChange={(e) => {
|
||||
const nextVal = e.target.value as
|
||||
| "none"
|
||||
| "read"
|
||||
| "write";
|
||||
setLocalTenantPermissions((prev) => ({
|
||||
...prev,
|
||||
[user.userId]: {
|
||||
...(prev[user.userId] ?? {}),
|
||||
worksmobile: nextVal,
|
||||
},
|
||||
}));
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"worksmobile",
|
||||
worksmobileVal,
|
||||
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"
|
||||
|
||||
@@ -582,6 +582,9 @@ export function parseOrgChartUserSelections(
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
rootTenantName?: string;
|
||||
leafTenantName?: string;
|
||||
tenantName?: string;
|
||||
} =>
|
||||
selection?.type === "user" &&
|
||||
typeof selection.id === "string" &&
|
||||
|
||||
@@ -45,6 +45,8 @@ export type TenantSummary = {
|
||||
manage_organization?: boolean;
|
||||
view_schema?: boolean;
|
||||
manage_schema?: boolean;
|
||||
view_worksmobile?: boolean;
|
||||
manage_worksmobile?: boolean;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user