forked from baron/baron-sso
@@ -30,20 +30,26 @@ const toastBase = (message: string, type: ToastType = "success") => {
|
|||||||
|
|
||||||
export const toast = Object.assign(toastBase, {
|
export const toast = Object.assign(toastBase, {
|
||||||
success: (message: string, options?: { description?: string }) => {
|
success: (message: string, options?: { description?: string }) => {
|
||||||
const finalMessage = options?.description ? `${message}
|
const finalMessage = options?.description
|
||||||
${options.description}` : message;
|
? `${message}
|
||||||
|
${options.description}`
|
||||||
|
: message;
|
||||||
toastBase(finalMessage, "success");
|
toastBase(finalMessage, "success");
|
||||||
},
|
},
|
||||||
error: (message: string, options?: { description?: string }) => {
|
error: (message: string, options?: { description?: string }) => {
|
||||||
const finalMessage = options?.description ? `${message}
|
const finalMessage = options?.description
|
||||||
${options.description}` : message;
|
? `${message}
|
||||||
|
${options.description}`
|
||||||
|
: message;
|
||||||
toastBase(finalMessage, "error");
|
toastBase(finalMessage, "error");
|
||||||
},
|
},
|
||||||
info: (message: string, options?: { description?: string }) => {
|
info: (message: string, options?: { description?: string }) => {
|
||||||
const finalMessage = options?.description ? `${message}
|
const finalMessage = options?.description
|
||||||
${options.description}` : message;
|
? `${message}
|
||||||
|
${options.description}`
|
||||||
|
: message;
|
||||||
toastBase(finalMessage, "info");
|
toastBase(finalMessage, "info");
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useToastState = () => {
|
export const useToastState = () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { Download, FileText, Loader2, Upload } from "lucide-react";
|
import { Download, FileText, Loader2, Upload } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import { importOrgChart } from "../../../lib/adminApi";
|
import { importOrgChart } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +38,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
addTenantAdmin,
|
addTenantAdmin,
|
||||||
addTenantOwner,
|
addTenantOwner,
|
||||||
@@ -291,25 +291,29 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
{owner.email}
|
{owner.email}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<span className="relative inline-block group/tt">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
size="icon"
|
||||||
owner.id === currentUserId ||
|
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||||
currentOwners.length <= 1
|
owner.id === currentUserId ||
|
||||||
? "opacity-50 cursor-not-allowed"
|
currentOwners.length <= 1
|
||||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||||
}`}
|
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
onClick={() =>
|
}`}
|
||||||
handleRemoveOwner(owner.id, owner.name)
|
onClick={() =>
|
||||||
}
|
handleRemoveOwner(owner.id, owner.name)
|
||||||
disabled={
|
}
|
||||||
removeOwnerMutation.isPending ||
|
disabled={
|
||||||
owner.id === currentUserId ||
|
removeOwnerMutation.isPending ||
|
||||||
currentOwners.length <= 1
|
owner.id === currentUserId ||
|
||||||
}
|
currentOwners.length <= 1
|
||||||
title={
|
}
|
||||||
owner.id === currentUserId
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||||
|
{owner.id === currentUserId
|
||||||
? t(
|
? t(
|
||||||
"msg.admin.tenants.owners.remove_self",
|
"msg.admin.tenants.owners.remove_self",
|
||||||
"본인의 권한은 회수할 수 없습니다.",
|
"본인의 권한은 회수할 수 없습니다.",
|
||||||
@@ -322,11 +326,9 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
: t(
|
: t(
|
||||||
"ui.admin.tenants.owners.remove_title",
|
"ui.admin.tenants.owners.remove_title",
|
||||||
"소유자 권한 회수",
|
"소유자 권한 회수",
|
||||||
)
|
)}
|
||||||
}
|
</span>
|
||||||
>
|
</span>
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@@ -420,25 +422,29 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
{admin.email}
|
{admin.email}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<span className="relative inline-block group/tt">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
size="icon"
|
||||||
admin.id === currentUserId ||
|
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||||
currentAdmins.length <= 1
|
admin.id === currentUserId ||
|
||||||
? "opacity-50 cursor-not-allowed"
|
currentAdmins.length <= 1
|
||||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||||
}`}
|
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
onClick={() =>
|
}`}
|
||||||
handleRemoveAdmin(admin.id, admin.name)
|
onClick={() =>
|
||||||
}
|
handleRemoveAdmin(admin.id, admin.name)
|
||||||
disabled={
|
}
|
||||||
removeAdminMutation.isPending ||
|
disabled={
|
||||||
admin.id === currentUserId ||
|
removeAdminMutation.isPending ||
|
||||||
currentAdmins.length <= 1
|
admin.id === currentUserId ||
|
||||||
}
|
currentAdmins.length <= 1
|
||||||
title={
|
}
|
||||||
admin.id === currentUserId
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
|
||||||
|
{admin.id === currentUserId
|
||||||
? t(
|
? t(
|
||||||
"msg.admin.tenants.admins.remove_self",
|
"msg.admin.tenants.admins.remove_self",
|
||||||
"본인의 권한은 회수할 수 없습니다.",
|
"본인의 권한은 회수할 수 없습니다.",
|
||||||
@@ -451,11 +457,9 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
: t(
|
: t(
|
||||||
"ui.admin.tenants.admins.remove_title",
|
"ui.admin.tenants.admins.remove_title",
|
||||||
"관리자 권한 회수",
|
"관리자 권한 회수",
|
||||||
)
|
)}
|
||||||
}
|
</span>
|
||||||
>
|
</span>
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +37,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
type GroupSummary,
|
type GroupSummary,
|
||||||
addGroupMember,
|
addGroupMember,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
|||||||
import { Save, Trash2 } from "lucide-react";
|
import { Save, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -15,10 +14,12 @@ import {
|
|||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { Textarea } from "../../../components/ui/textarea";
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
approveTenant,
|
approveTenant,
|
||||||
deleteTenant,
|
deleteTenant,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
|
fetchTenants,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
@@ -39,12 +40,21 @@ export function TenantProfilePage() {
|
|||||||
queryFn: () => fetchTenant(tenantId),
|
queryFn: () => fetchTenant(tenantId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parentQuery = useQuery({
|
||||||
|
queryKey: ["tenants", "list-all"],
|
||||||
|
queryFn: () => fetchTenants(1000, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableParents =
|
||||||
|
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [type, setType] = useState("COMPANY");
|
const [type, setType] = useState("COMPANY");
|
||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("active");
|
const [status, setStatus] = useState("active");
|
||||||
const [domains, setDomains] = useState("");
|
const [domains, setDomains] = useState("");
|
||||||
|
const [parentId, setParentId] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tenantQuery.data) {
|
if (tenantQuery.data) {
|
||||||
@@ -54,6 +64,7 @@ export function TenantProfilePage() {
|
|||||||
setDescription(tenantQuery.data.description ?? "");
|
setDescription(tenantQuery.data.description ?? "");
|
||||||
setStatus(tenantQuery.data.status);
|
setStatus(tenantQuery.data.status);
|
||||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||||
|
setParentId(tenantQuery.data.parentId ?? "");
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
@@ -65,6 +76,7 @@ export function TenantProfilePage() {
|
|||||||
slug,
|
slug,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
status,
|
status,
|
||||||
|
parentId: parentId || undefined,
|
||||||
domains: domains
|
domains: domains
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((d) => d.trim())
|
.map((d) => d.trim())
|
||||||
@@ -197,6 +209,31 @@ export function TenantProfilePage() {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="parentId" className="text-sm font-semibold">
|
||||||
|
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="parentId"
|
||||||
|
name="parentId"
|
||||||
|
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"
|
||||||
|
value={parentId}
|
||||||
|
onChange={(e) => setParentId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
|
||||||
|
{availableParents.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name} ({t.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.profile.form.parent_help",
|
||||||
|
"가족사 테넌트나 하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
|||||||
import { Plus, Save, Trash2 } from "lucide-react";
|
import { Plus, Save, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function TenantUsersPage() {
|
|||||||
// 해당 슬러그로 사용자 검색
|
// 해당 슬러그로 사용자 검색
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ["users", { tenantSlug }],
|
queryKey: ["users", { tenantSlug }],
|
||||||
queryFn: () => fetchUsers(100, 0, tenantSlug),
|
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
||||||
enabled: !!tenantSlug,
|
enabled: !!tenantSlug,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +54,7 @@ import {
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "../../../components/ui/tabs";
|
} from "../../../components/ui/tabs";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
type GroupSummary,
|
type GroupSummary,
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
@@ -775,7 +775,8 @@ const TenantTreeRow: React.FC<{
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (node.type === "USER_GROUP") {
|
if (node.type === "USER_GROUP") {
|
||||||
// User groups have a different detail path
|
// User groups have a different detail path
|
||||||
const baseTenantId = (node as any).tenantId || (node as any).parentId || "";
|
const baseTenantId =
|
||||||
|
(node as any).tenantId || (node as any).parentId || "";
|
||||||
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
|
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
|
||||||
} else {
|
} else {
|
||||||
navigate(`/tenants/${node.id}`);
|
navigate(`/tenants/${node.id}`);
|
||||||
@@ -866,6 +867,11 @@ function TenantUserGroupsTab() {
|
|||||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||||
setIsAddDialogOpen(false);
|
setIsAddDialogOpen(false);
|
||||||
},
|
},
|
||||||
|
onError: (error: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(t("msg.common.error", "오류 발생"), {
|
||||||
|
description: error.response?.data?.error || error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const allTenants = data?.items ?? [];
|
const allTenants = data?.items ?? [];
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
|||||||
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +38,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
addGroupMember,
|
addGroupMember,
|
||||||
assignGroupRole,
|
assignGroupRole,
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
type UserCreateRequest,
|
type UserCreateRequest,
|
||||||
type UserCreateResponse,
|
type UserCreateResponse,
|
||||||
createUser,
|
createUser,
|
||||||
|
fetchMe,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
fetchMe,
|
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
useForm,
|
useForm,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
type UserUpdateRequest,
|
type UserUpdateRequest,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { toast } from "../../components/ui/use-toast";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +41,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
bulkUpdateUsers,
|
bulkUpdateUsers,
|
||||||
@@ -132,10 +132,7 @@ function UserListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: [
|
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
|
||||||
"users",
|
|
||||||
{ limit, offset, search, tenantSlug: selectedCompany },
|
|
||||||
],
|
|
||||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
|
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { toast } from "../../../components/ui/use-toast";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||||
|
import { toast } from "../../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
type GroupSummary,
|
type GroupSummary,
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ export type BulkUserItem = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BulkUserResult = {
|
export type BulkUserResult = {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { AuthProvider } from "react-oidc-context";
|
|||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
import { queryClient } from "./app/queryClient";
|
import { queryClient } from "./app/queryClient";
|
||||||
import { router } from "./app/routes";
|
import { router } from "./app/routes";
|
||||||
import { oidcConfig } from "./lib/auth";
|
|
||||||
import { Toaster } from "./components/ui/toaster";
|
import { Toaster } from "./components/ui/toaster";
|
||||||
|
import { oidcConfig } from "./lib/auth";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ func main() {
|
|||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo)
|
||||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||||
|
|
||||||
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
||||||
|
|||||||
@@ -364,6 +364,24 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
if pid == "" {
|
if pid == "" {
|
||||||
tenant.ParentID = nil
|
tenant.ParentID = nil
|
||||||
} else {
|
} else {
|
||||||
|
// 순환 참조(Circular Dependency) 방지 로직:
|
||||||
|
// 새로운 부모(pid)부터 상위로 탐색하면서 현재 테넌트(tenant.ID)가 나오면 순환 참조로 간주함
|
||||||
|
checkID := pid
|
||||||
|
for checkID != "" {
|
||||||
|
if checkID == tenant.ID {
|
||||||
|
return errorJSON(c, fiber.StatusConflict, "순환 참조 오류: 하위 테넌트를 상위 테넌트로 지정할 수 없습니다.")
|
||||||
|
}
|
||||||
|
var pTenant domain.Tenant
|
||||||
|
if err := h.DB.Select("id, parent_id").First(&pTenant, "id = ?", checkID).Error; err != nil {
|
||||||
|
break // 데이터를 찾을 수 없거나 에러 발생 시 반복문 종료 (추후 외래키 제약조건 등에서 에러 발생)
|
||||||
|
}
|
||||||
|
if pTenant.ParentID != nil {
|
||||||
|
checkID = *pTenant.ParentID
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tenant.ParentID = &pid
|
tenant.ParentID = &pid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ type UserHandler struct {
|
|||||||
KetoService service.KetoService
|
KetoService service.KetoService
|
||||||
KetoOutboxRepo repository.KetoOutboxRepository
|
KetoOutboxRepo repository.KetoOutboxRepository
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
|
UserGroupRepo repository.UserGroupRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository) *UserHandler {
|
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository) *UserHandler {
|
||||||
return &UserHandler{
|
return &UserHandler{
|
||||||
KratosAdmin: kratosAdmin,
|
KratosAdmin: kratosAdmin,
|
||||||
OryProvider: oryProvider,
|
OryProvider: oryProvider,
|
||||||
@@ -44,6 +45,7 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi
|
|||||||
KetoService: ketoService,
|
KetoService: ketoService,
|
||||||
KetoOutboxRepo: ketoOutboxRepo,
|
KetoOutboxRepo: ketoOutboxRepo,
|
||||||
UserRepo: userRepo,
|
UserRepo: userRepo,
|
||||||
|
UserGroupRepo: userGroupRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +85,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
|
||||||
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
@@ -125,8 +127,8 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedicated companyCode filter
|
// Dedicated tenantSlug filter
|
||||||
if companyCode != "" && !strings.EqualFold(compCode, companyCode) {
|
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +178,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
|
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
|
||||||
|
|
||||||
// Fetch from UserRepo
|
// Fetch from UserRepo
|
||||||
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode)
|
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
|
||||||
}
|
}
|
||||||
@@ -414,7 +416,7 @@ type bulkUserItem struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
CompanyCode string `json:"companyCode"`
|
TenantSlug string `json:"tenantSlug"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
Metadata map[string]any `json:"metadata"`
|
Metadata map[string]any `json:"metadata"`
|
||||||
}
|
}
|
||||||
@@ -456,13 +458,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
type tenantCacheItem struct {
|
type tenantCacheItem struct {
|
||||||
ID string
|
ID string
|
||||||
Schema []interface{}
|
Schema []interface{}
|
||||||
|
Groups []domain.UserGroup
|
||||||
}
|
}
|
||||||
tenantCache := make(map[string]tenantCacheItem)
|
tenantCache := make(map[string]tenantCacheItem)
|
||||||
|
|
||||||
for _, item := range req.Users {
|
for _, item := range req.Users {
|
||||||
email := strings.TrimSpace(item.Email)
|
email := strings.TrimSpace(item.Email)
|
||||||
name := strings.TrimSpace(item.Name)
|
name := strings.TrimSpace(item.Name)
|
||||||
compCode := strings.TrimSpace(item.CompanyCode)
|
tenantSlug := strings.TrimSpace(item.TenantSlug)
|
||||||
dept := strings.TrimSpace(item.Department)
|
dept := strings.TrimSpace(item.Department)
|
||||||
|
|
||||||
if email == "" || name == "" {
|
if email == "" || name == "" {
|
||||||
@@ -470,14 +473,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if compCode == "" {
|
if tenantSlug == "" {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "companyCode (tenant) is required"})
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantSlug is required"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role-based access check
|
// Role-based access check
|
||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
if compCode != requester.CompanyCode {
|
if tenantSlug != requester.CompanyCode {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -486,18 +489,25 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
// Verify Tenant Existence and Resolve ID (with Cache)
|
// Verify Tenant Existence and Resolve ID (with Cache)
|
||||||
var tItem tenantCacheItem
|
var tItem tenantCacheItem
|
||||||
var exists bool
|
var exists bool
|
||||||
if tItem, exists = tenantCache[compCode]; !exists {
|
if tItem, exists = tenantCache[tenantSlug]; !exists {
|
||||||
if h.TenantService != nil {
|
if h.TenantService != nil {
|
||||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode)
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||||
if err != nil || tenant == nil {
|
if err != nil || tenant == nil {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid companyCode: tenant not found"})
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid tenantSlug: tenant not found"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tItem.ID = tenant.ID
|
tItem.ID = tenant.ID
|
||||||
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
||||||
tItem.Schema = s
|
tItem.Schema = s
|
||||||
}
|
}
|
||||||
tenantCache[compCode] = tItem
|
// [Fix] Cache user groups for this tenant to match department
|
||||||
|
if h.UserGroupRepo != nil {
|
||||||
|
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
|
||||||
|
tItem.Groups = groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantCache[tenantSlug] = tItem
|
||||||
} else {
|
} else {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"})
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"})
|
||||||
continue
|
continue
|
||||||
@@ -521,7 +531,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
attributes := map[string]interface{}{
|
attributes := map[string]interface{}{
|
||||||
"department": dept,
|
"department": dept,
|
||||||
"affiliationType": "internal",
|
"affiliationType": "internal",
|
||||||
"companyCode": compCode,
|
"companyCode": tenantSlug,
|
||||||
"tenant_id": tItem.ID,
|
"tenant_id": tItem.ID,
|
||||||
"grade": role,
|
"grade": role,
|
||||||
"role": role,
|
"role": role,
|
||||||
@@ -541,8 +551,18 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
Attributes: attributes,
|
Attributes: attributes,
|
||||||
}, password)
|
}, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
|
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
|
||||||
continue
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
|
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email)
|
||||||
|
if err != nil || identityID == "" {
|
||||||
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: "이미 존재하는 사용자지만 ID를 찾을 수 없습니다."})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
|
||||||
|
} else {
|
||||||
|
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [CRITICAL FIX] Sync to local DB directly using current data
|
// [CRITICAL FIX] Sync to local DB directly using current data
|
||||||
@@ -555,7 +575,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
Phone: normalizePhoneNumber(item.Phone),
|
Phone: normalizePhoneNumber(item.Phone),
|
||||||
Role: role,
|
Role: role,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
CompanyCode: compCode,
|
CompanyCode: tenantSlug,
|
||||||
Department: dept,
|
Department: dept,
|
||||||
AffiliationType: "internal",
|
AffiliationType: "internal",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -590,6 +610,22 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
Action: domain.KetoOutboxActionCreate,
|
Action: domain.KetoOutboxActionCreate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Sync membership to UserGroup if department matches
|
||||||
|
if dept != "" {
|
||||||
|
for _, g := range tItem.Groups {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(g.Name), dept) {
|
||||||
|
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: g.ID,
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:" + localUser.ID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user