forked from baron/baron-sso
Merge pull request 'feature/af-ui' (#449) from feature/af-ui into dev
Reviewed-on: baron/baron-sso#449
This commit is contained in:
42
adminfront/package-lock.json
generated
42
adminfront/package-lock.json
generated
@@ -26,7 +26,6 @@
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
@@ -39,6 +38,7 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"jsdom": "^28.1.0",
|
||||
@@ -2659,6 +2659,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
||||
@@ -2689,6 +2696,29 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router": {
|
||||
"version": "5.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
|
||||
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router-dom": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
|
||||
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*",
|
||||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
||||
@@ -5156,16 +5186,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
@@ -47,6 +46,7 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"jsdom": "^28.1.0",
|
||||
|
||||
@@ -60,10 +60,5 @@ export const router = createBrowserRouter(
|
||||
],
|
||||
},
|
||||
],
|
||||
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
|
||||
{
|
||||
future: {
|
||||
v7_startTransition: true,
|
||||
},
|
||||
} as unknown as Parameters<typeof createBrowserRouter>[1],
|
||||
// React Router v7 플래그는 Provider에서 적용합니다.
|
||||
);
|
||||
|
||||
35
adminfront/src/components/ui/toaster.tsx
Normal file
35
adminfront/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useToastState } from "./use-toast";
|
||||
|
||||
export function Toaster() {
|
||||
const toasts = useToastState();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-[320px]">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
|
||||
t.type === "success" &&
|
||||
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
|
||||
t.type === "error" &&
|
||||
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
|
||||
t.type === "info" &&
|
||||
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
|
||||
)}
|
||||
>
|
||||
{t.type === "success" && (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
|
||||
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
|
||||
<p className="text-sm font-medium leading-none">{t.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
adminfront/src/components/ui/use-toast.ts
Normal file
66
adminfront/src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
|
||||
type ToastType = "success" | "error" | "info";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
let subscribers: ((toasts: Toast[]) => void)[] = [];
|
||||
let toasts: Toast[] = [];
|
||||
|
||||
const notify = () => {
|
||||
for (const sub of subscribers) {
|
||||
sub(toasts);
|
||||
}
|
||||
};
|
||||
|
||||
const toastBase = (message: string, type: ToastType = "success") => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
toasts = [...toasts, { id, message, type }];
|
||||
notify();
|
||||
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
notify();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
export const toast = Object.assign(toastBase, {
|
||||
success: (message: string, options?: { description?: string }) => {
|
||||
const finalMessage = options?.description
|
||||
? `${message}
|
||||
${options.description}`
|
||||
: message;
|
||||
toastBase(finalMessage, "success");
|
||||
},
|
||||
error: (message: string, options?: { description?: string }) => {
|
||||
const finalMessage = options?.description
|
||||
? `${message}
|
||||
${options.description}`
|
||||
: message;
|
||||
toastBase(finalMessage, "error");
|
||||
},
|
||||
info: (message: string, options?: { description?: string }) => {
|
||||
const finalMessage = options?.description
|
||||
? `${message}
|
||||
${options.description}`
|
||||
: message;
|
||||
toastBase(finalMessage, "info");
|
||||
},
|
||||
});
|
||||
|
||||
export const useToastState = () => {
|
||||
const [state, setState] = React.useState<Toast[]>(toasts);
|
||||
|
||||
React.useEffect(() => {
|
||||
subscribers.push(setState);
|
||||
return () => {
|
||||
subscribers = subscribers.filter((sub) => sub !== setState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, FileText, Loader2, Upload } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { importOrgChart } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addTenantAdmin,
|
||||
addTenantOwner,
|
||||
@@ -291,25 +291,29 @@ export function TenantAdminsAndOwnersTab() {
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
title={
|
||||
owner.id === currentUserId
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
>
|
||||
<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(
|
||||
"msg.admin.tenants.owners.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
@@ -322,11 +326,9 @@ export function TenantAdminsAndOwnersTab() {
|
||||
: t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -420,25 +422,29 @@ export function TenantAdminsAndOwnersTab() {
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
title={
|
||||
admin.id === currentUserId
|
||||
<span className="relative inline-block group/tt">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
>
|
||||
<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(
|
||||
"msg.admin.tenants.admins.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
@@ -451,11 +457,9 @@ export function TenantAdminsAndOwnersTab() {
|
||||
: t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type GroupSummary,
|
||||
addGroupMember,
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -15,10 +14,12 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -39,12 +40,21 @@ export function TenantProfilePage() {
|
||||
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 [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantQuery.data) {
|
||||
@@ -54,6 +64,7 @@ export function TenantProfilePage() {
|
||||
setDescription(tenantQuery.data.description ?? "");
|
||||
setStatus(tenantQuery.data.status);
|
||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||
setParentId(tenantQuery.data.parentId ?? "");
|
||||
}
|
||||
}, [tenantQuery.data]);
|
||||
|
||||
@@ -65,6 +76,7 @@ export function TenantProfilePage() {
|
||||
slug,
|
||||
description: description || undefined,
|
||||
status,
|
||||
parentId: parentId || undefined,
|
||||
domains: domains
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
@@ -197,6 +209,31 @@ export function TenantProfilePage() {
|
||||
</option>
|
||||
</select>
|
||||
</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">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
||||
import { Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
} from "../../../components/ui/card";
|
||||
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 { t } from "../../../lib/i18n";
|
||||
|
||||
|
||||
@@ -23,20 +23,20 @@ function TenantUsersPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
|
||||
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
|
||||
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const companyCode = tenantQuery.data?.slug;
|
||||
const tenantSlug = tenantQuery.data?.slug;
|
||||
|
||||
// 해당 슬러그로 사용자 검색
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["users", { companyCode }],
|
||||
queryFn: () => fetchUsers(100, 0, companyCode),
|
||||
enabled: !!companyCode,
|
||||
queryKey: ["users", { tenantSlug }],
|
||||
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
||||
enabled: !!tenantSlug,
|
||||
});
|
||||
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -55,6 +54,7 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../../components/ui/tabs";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
@@ -266,7 +266,7 @@ const MemberTable: React.FC<{
|
||||
<Users size={40} />
|
||||
<p>{t("msg.admin.users.list.empty", "멤버가 없습니다.")}</p>
|
||||
<Button
|
||||
variant="link"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="mt-2"
|
||||
@@ -290,7 +290,7 @@ const MemberTable: React.FC<{
|
||||
{showTenant && (
|
||||
<TableCell className="py-2">
|
||||
<Badge variant="outline" className="text-[10px] h-5">
|
||||
{user.companyCode}
|
||||
{user.tenantSlug}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -360,7 +360,7 @@ const UserAddDialog: React.FC<{
|
||||
const res = await createUser({
|
||||
email,
|
||||
name,
|
||||
companyCode: tenantSlug,
|
||||
tenantSlug: tenantSlug,
|
||||
role: "user",
|
||||
});
|
||||
toast.success(
|
||||
@@ -369,7 +369,6 @@ const UserAddDialog: React.FC<{
|
||||
description: res.initialPassword
|
||||
? `초기 비밀번호: ${res.initialPassword}`
|
||||
: undefined,
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -395,7 +394,7 @@ const UserAddDialog: React.FC<{
|
||||
if (!selectedUserId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateUser(selectedUserId, { companyCode: tenantSlug });
|
||||
await updateUser(selectedUserId, { tenantSlug: tenantSlug });
|
||||
toast.success(
|
||||
t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."),
|
||||
);
|
||||
@@ -505,12 +504,12 @@ const UserAddDialog: React.FC<{
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
{user.companyCode && (
|
||||
{user.tenantSlug && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] mt-1"
|
||||
>
|
||||
{user.companyCode}
|
||||
{user.tenantSlug}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -776,7 +775,10 @@ const TenantTreeRow: React.FC<{
|
||||
onClick={() => {
|
||||
if (node.type === "USER_GROUP") {
|
||||
// User groups have a different detail path
|
||||
const baseTenantId = node.tenantId || tenantId;
|
||||
const baseTenantId =
|
||||
(node as unknown as { tenantId?: string }).tenantId ||
|
||||
node.parentId ||
|
||||
"";
|
||||
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
|
||||
} else {
|
||||
navigate(`/tenants/${node.id}`);
|
||||
@@ -867,6 +869,11 @@ function TenantUserGroupsTab() {
|
||||
toast.success(t("msg.info.saved_success", "저장되었습니다."));
|
||||
setIsAddDialogOpen(false);
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
toast.error(t("msg.common.error", "오류 발생"), {
|
||||
description: error.response?.data?.error || error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const allTenants = data?.items ?? [];
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
||||
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addGroupMember,
|
||||
assignGroupRole,
|
||||
@@ -189,7 +189,7 @@ export function UserGroupDetailPage() {
|
||||
<p>
|
||||
Error:{" "}
|
||||
{(error as AxiosError<{ error?: string }>)?.response?.data?.error ||
|
||||
error.message ||
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
"Not found"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
createUser,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
} from "../../lib/adminApi";
|
||||
@@ -68,7 +69,7 @@ function UserCreatePage() {
|
||||
name: "",
|
||||
phone: "",
|
||||
role: "user",
|
||||
companyCode: "",
|
||||
tenantSlug: "",
|
||||
department: "",
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
@@ -78,13 +79,13 @@ function UserCreatePage() {
|
||||
|
||||
// Lock company for tenant_admin
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin" && profile.companyCode) {
|
||||
setValue("companyCode", profile.companyCode);
|
||||
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
|
||||
setValue("tenantSlug", profile.tenantSlug);
|
||||
}
|
||||
}, [profile, setValue]);
|
||||
|
||||
const selectedCompanyCode = watch("companyCode");
|
||||
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||
const selectedTenantSlug = watch("tenantSlug");
|
||||
const selectedTenant = tenants.find((t) => t.slug === selectedTenantSlug);
|
||||
|
||||
const selectedTenantId = selectedTenant?.id ?? "";
|
||||
|
||||
@@ -351,15 +352,15 @@ function UserCreatePage() {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyCode">
|
||||
<Label htmlFor="tenantSlug">
|
||||
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
id="companyCode"
|
||||
id="tenantSlug"
|
||||
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"
|
||||
{...register("companyCode")}
|
||||
{...register("tenantSlug")}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type TenantSummary,
|
||||
type UserUpdateRequest,
|
||||
@@ -147,9 +147,21 @@ function TenantProfileCard({
|
||||
: false,
|
||||
})}
|
||||
/>
|
||||
{errors.metadata?.[tenant.id]?.[field.key] && (
|
||||
{(
|
||||
errors.metadata as unknown as Record<
|
||||
string,
|
||||
Record<string, { message?: string }>
|
||||
>
|
||||
)?.[tenant.id]?.[field.key] && (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{errors.metadata[tenant.id][field.key].message}
|
||||
{
|
||||
(
|
||||
errors.metadata as unknown as Record<
|
||||
string,
|
||||
Record<string, { message?: string }>
|
||||
>
|
||||
)?.[tenant.id]?.[field.key]?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -202,7 +214,7 @@ function UserDetailPage() {
|
||||
phone: "",
|
||||
role: "user",
|
||||
status: "active",
|
||||
companyCode: "",
|
||||
tenantSlug: "",
|
||||
department: "",
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
@@ -243,12 +255,15 @@ function UserDetailPage() {
|
||||
phone: user.phone || "",
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
companyCode: user.companyCode || "",
|
||||
tenantSlug: user.tenantSlug || "",
|
||||
department: user.department || "",
|
||||
position: user.position || "",
|
||||
jobTitle: user.jobTitle || "",
|
||||
password: "",
|
||||
metadata: user.metadata || {},
|
||||
metadata: (user.metadata || {}) as unknown as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>,
|
||||
});
|
||||
}
|
||||
}, [user, reset]);
|
||||
@@ -377,7 +392,7 @@ function UserDetailPage() {
|
||||
<div className="relative">
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
|
||||
{...register("companyCode")}
|
||||
{...register("tenantSlug")}
|
||||
disabled={
|
||||
profile?.role === "tenant_admin" &&
|
||||
userAffiliatedTenants.length <= 1
|
||||
@@ -428,7 +443,7 @@ function UserDetailPage() {
|
||||
to={`/tenants/${jt.id}`}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
|
||||
jt.id ===
|
||||
tenants.find((t) => t.slug === watch("companyCode"))
|
||||
tenants.find((t) => t.slug === watch("tenantSlug"))
|
||||
?.id
|
||||
? "bg-primary/10 border-primary/30 text-primary font-bold"
|
||||
: "bg-background border-border text-muted-foreground hover:border-primary/50"
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
@@ -87,8 +88,8 @@ function UserListPage() {
|
||||
|
||||
// Lock company for tenant_admin
|
||||
React.useEffect(() => {
|
||||
if (profile?.role === "tenant_admin" && profile.companyCode) {
|
||||
setSelectedCompany(profile.companyCode);
|
||||
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
|
||||
setSelectedCompany(profile.tenantSlug);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
@@ -131,10 +132,7 @@ function UserListPage() {
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [
|
||||
"users",
|
||||
{ limit, offset, search, companyCode: selectedCompany },
|
||||
],
|
||||
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
|
||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
@@ -530,7 +528,7 @@ function UserListPage() {
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.companyCode || "-"}
|
||||
{user.tenant?.name || user.tenantSlug || "-"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
} 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 {
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
@@ -138,7 +138,7 @@ export function UserBulkMoveGroupModal({
|
||||
if (!selectedTenantSlug) return;
|
||||
mutation.mutate({
|
||||
userIds,
|
||||
companyCode: selectedTenantSlug,
|
||||
tenantSlug: selectedTenantSlug,
|
||||
department: selectedGroupName, // can be empty for "No Department"
|
||||
});
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,phone,role,companyCode,department,employee_id";
|
||||
const headers = "email,name,phone,role,tenant,department,employee_id";
|
||||
const example =
|
||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
||||
const blob = new Blob(
|
||||
@@ -203,7 +203,7 @@ ${example}`,
|
||||
<tr key={u.email} className="border-t">
|
||||
<td className="p-2">{u.email}</td>
|
||||
<td className="p-2">{u.name}</td>
|
||||
<td className="p-2">{u.companyCode || "-"}</td>
|
||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{previewData.length > 10 && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parseUserCSV } from "./csvParser";
|
||||
|
||||
describe("parseUserCSV", () => {
|
||||
it("should parse valid CSV correctly", () => {
|
||||
const csv = `email,name,phone,role,companyCode,department,emp_id
|
||||
const csv = `email,name,phone,role,tenant,department,emp_id
|
||||
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
|
||||
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
|
||||
|
||||
@@ -15,7 +15,7 @@ user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
|
||||
name: "Hong Gil Dong",
|
||||
phone: "010-1111-2222",
|
||||
role: "user",
|
||||
companyCode: "baron",
|
||||
tenantSlug: "baron",
|
||||
department: "HR",
|
||||
metadata: {
|
||||
emp_id: "E001",
|
||||
@@ -37,10 +37,10 @@ no-name@test.com,`;
|
||||
});
|
||||
|
||||
it("should handle mixed case headers", () => {
|
||||
const csv = `EMAIL,Name,CompanyCode
|
||||
const csv = `EMAIL,Name,Tenant
|
||||
test@test.com,Test,baron`;
|
||||
const result = parseUserCSV(csv);
|
||||
expect(result[0].email).toBe("test@test.com");
|
||||
expect(result[0].companyCode).toBe("baron");
|
||||
expect(result[0].tenantSlug).toBe("baron");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,8 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
item.phone = value;
|
||||
} else if (header === "role") {
|
||||
item.role = value;
|
||||
} else if (header === "companycode") {
|
||||
item.companyCode = value;
|
||||
} else if (header === "tenant") {
|
||||
item.tenantSlug = value;
|
||||
} else if (header === "department") {
|
||||
item.department = value;
|
||||
} else {
|
||||
|
||||
@@ -352,7 +352,7 @@ export type UserSummary = {
|
||||
phone?: string;
|
||||
role: string;
|
||||
status: string;
|
||||
companyCode?: string;
|
||||
tenantSlug?: string;
|
||||
tenant?: TenantSummary;
|
||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -376,10 +376,11 @@ export type UserCreateRequest = {
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyCode?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type UserCreateResponse = UserSummary & {
|
||||
@@ -392,10 +393,11 @@ export type UserUpdateRequest = {
|
||||
phone?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
companyCode?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
jobTitle?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type BulkUserItem = {
|
||||
@@ -403,9 +405,9 @@ export type BulkUserItem = {
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyCode?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
export type BulkUserResult = {
|
||||
@@ -423,10 +425,10 @@ export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
search?: string,
|
||||
companyCode?: string,
|
||||
tenantSlug?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||
params: { limit, offset, search, companyCode },
|
||||
params: { limit, offset, search, tenantSlug },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -446,10 +448,10 @@ export async function createUser(payload: UserCreateRequest) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export function exportUsersCSVUrl(search?: string, companyCode?: string) {
|
||||
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.append("search", search);
|
||||
if (companyCode) params.append("companyCode", companyCode);
|
||||
if (tenantSlug) params.append("tenantSlug", tenantSlug);
|
||||
|
||||
// Get mock role from storage if exists for dev environment
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
@@ -471,6 +473,8 @@ export async function bulkUpdateUsers(payload: {
|
||||
userIds: string[];
|
||||
status?: string;
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
department?: string;
|
||||
}) {
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||
return data;
|
||||
@@ -503,7 +507,7 @@ export type UserProfileResponse = {
|
||||
role: string;
|
||||
department: string;
|
||||
affiliationType: string;
|
||||
companyCode?: string;
|
||||
tenantSlug?: string;
|
||||
tenantId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
tenant?: TenantSummary;
|
||||
|
||||
@@ -8,15 +8,17 @@ describe("i18n utility", () => {
|
||||
});
|
||||
|
||||
it("returns fallback if key not found", () => {
|
||||
expect(t("non.existent.key", "Fallback")).toBe("Fallback");
|
||||
expect(t("this.key.truly.does.not.exist", "Fallback")).toBe("Fallback");
|
||||
});
|
||||
|
||||
it("returns key if fallback not provided and key not found", () => {
|
||||
expect(t("non.existent.key")).toBe("non.existent.key");
|
||||
expect(t("this.key.truly.does.not.exist")).toBe(
|
||||
"this.key.truly.does.not.exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces variables in template", () => {
|
||||
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe(
|
||||
expect(t("this.test.key", "Hello {{ name }}", { name: "World" })).toBe(
|
||||
"Hello World",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthProvider } from "react-oidc-context";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/routes";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { oidcConfig } from "./lib/auth";
|
||||
import "./index.css";
|
||||
|
||||
@@ -18,7 +19,13 @@ createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AuthProvider {...oidcConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
/>
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -70,7 +70,7 @@ test.describe("User Schema Dynamic Form", () => {
|
||||
id: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
companyCode: "test-tenant",
|
||||
tenantSlug: "test-tenant",
|
||||
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||
joinedTenants: [
|
||||
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
|
||||
@@ -282,7 +282,7 @@ func main() {
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
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)
|
||||
|
||||
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
|
||||
|
||||
@@ -364,6 +364,24 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
if pid == "" {
|
||||
tenant.ParentID = nil
|
||||
} 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
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,10 @@ type UserHandler struct {
|
||||
KetoService service.KetoService
|
||||
KetoOutboxRepo repository.KetoOutboxRepository
|
||||
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{
|
||||
KratosAdmin: kratosAdmin,
|
||||
OryProvider: oryProvider,
|
||||
@@ -44,6 +45,7 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi
|
||||
KetoService: ketoService,
|
||||
KetoOutboxRepo: ketoOutboxRepo,
|
||||
UserRepo: userRepo,
|
||||
UserGroupRepo: userGroupRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +85,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
||||
tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
@@ -125,8 +127,8 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated companyCode filter
|
||||
if companyCode != "" && !strings.EqualFold(compCode, companyCode) {
|
||||
// Dedicated tenantSlug filter
|
||||
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) {
|
||||
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)
|
||||
|
||||
// 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 {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
|
||||
}
|
||||
@@ -410,13 +412,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
type bulkUserItem struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
Department string `json:"department"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
TenantSlug string `json:"tenantSlug"`
|
||||
Department string `json:"department"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
type bulkUserResult struct {
|
||||
@@ -456,13 +458,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
type tenantCacheItem struct {
|
||||
ID string
|
||||
Schema []interface{}
|
||||
Groups []domain.UserGroup
|
||||
}
|
||||
tenantCache := make(map[string]tenantCacheItem)
|
||||
|
||||
for _, item := range req.Users {
|
||||
email := strings.TrimSpace(item.Email)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
compCode := strings.TrimSpace(item.CompanyCode)
|
||||
tenantSlug := strings.TrimSpace(item.TenantSlug)
|
||||
dept := strings.TrimSpace(item.Department)
|
||||
|
||||
if email == "" || name == "" {
|
||||
@@ -470,14 +473,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if compCode == "" {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "companyCode (tenant) is required"})
|
||||
if tenantSlug == "" {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantSlug is required"})
|
||||
continue
|
||||
}
|
||||
|
||||
// Role-based access check
|
||||
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"})
|
||||
continue
|
||||
}
|
||||
@@ -486,18 +489,25 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
// Verify Tenant Existence and Resolve ID (with Cache)
|
||||
var tItem tenantCacheItem
|
||||
var exists bool
|
||||
if tItem, exists = tenantCache[compCode]; !exists {
|
||||
if tItem, exists = tenantCache[tenantSlug]; !exists {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
tItem.ID = tenant.ID
|
||||
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
||||
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 {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"})
|
||||
continue
|
||||
@@ -521,7 +531,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
attributes := map[string]interface{}{
|
||||
"department": dept,
|
||||
"affiliationType": "internal",
|
||||
"companyCode": compCode,
|
||||
"companyCode": tenantSlug,
|
||||
"tenant_id": tItem.ID,
|
||||
"grade": role,
|
||||
"role": role,
|
||||
@@ -541,8 +551,18 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
Attributes: attributes,
|
||||
}, password)
|
||||
if err != nil {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
|
||||
continue
|
||||
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
|
||||
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
|
||||
@@ -555,7 +575,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
Phone: normalizePhoneNumber(item.Phone),
|
||||
Role: role,
|
||||
Status: "active",
|
||||
CompanyCode: compCode,
|
||||
CompanyCode: tenantSlug,
|
||||
Department: dept,
|
||||
AffiliationType: "internal",
|
||||
CreatedAt: time.Now(),
|
||||
@@ -590,6 +610,22 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,13 +122,13 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
{
|
||||
"email": "user1@test.com",
|
||||
"name": "User One",
|
||||
"companyCode": "test-tenant",
|
||||
"tenantSlug": "test-tenant",
|
||||
"metadata": map[string]interface{}{"emp_id": "E001"},
|
||||
},
|
||||
{
|
||||
"email": "user2@test.com",
|
||||
"name": "User Two",
|
||||
"companyCode": "test-tenant",
|
||||
"tenantSlug": "test-tenant",
|
||||
"metadata": map[string]interface{}{"emp_id": "E002"},
|
||||
},
|
||||
},
|
||||
@@ -155,9 +155,9 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"users": []map[string]interface{}{
|
||||
{
|
||||
"email": "fail@test.com",
|
||||
"name": "Fail User",
|
||||
"companyCode": "wrong-tenant",
|
||||
"email": "fail@test.com",
|
||||
"name": "Fail User",
|
||||
"tenantSlug": "wrong-tenant",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -188,10 +188,10 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"users": []map[string]interface{}{
|
||||
{
|
||||
"email": "missing-meta@test.com",
|
||||
"name": "No Meta",
|
||||
"companyCode": "test-tenant",
|
||||
"metadata": map[string]interface{}{}, // emp_id missing
|
||||
"email": "missing-meta@test.com",
|
||||
"name": "No Meta",
|
||||
"tenantSlug": "test-tenant",
|
||||
"metadata": map[string]interface{}{}, // emp_id missing
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -222,10 +222,10 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"users": []map[string]interface{}{
|
||||
{
|
||||
"email": "regex-fail@test.com",
|
||||
"name": "Regex Fail",
|
||||
"companyCode": "test-tenant",
|
||||
"metadata": map[string]interface{}{"emp_id": "abc"}, // Should start with E and 3 digits
|
||||
"email": "regex-fail@test.com",
|
||||
"name": "Regex Fail",
|
||||
"tenantSlug": "test-tenant",
|
||||
"metadata": map[string]interface{}{"emp_id": "abc"}, // Should start with E and 3 digits
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1760,3 +1760,7 @@ verify = "Verification"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Go to sign-in"
|
||||
|
||||
[ui.admin.tenants.profile.form]
|
||||
parent = "Parent Tenant (Optional)"
|
||||
parent_help = "Select a parent tenant if this is a subsidiary or sub-organization."
|
||||
|
||||
2660
locales/ko.toml
2660
locales/ko.toml
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,10 @@ function walkDir(dirPath, files) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,10 @@ function walkDir(dirPath, files) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user