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-hook-form": "^7.71.1",
|
||||||
"react-oidc-context": "^3.3.0",
|
"react-oidc-context": "^3.3.0",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^6.28.2",
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
@@ -39,6 +38,7 @@
|
|||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
@@ -2659,6 +2659,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.9",
|
"version": "24.10.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
|
||||||
@@ -2689,6 +2696,29 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
||||||
@@ -5156,16 +5186,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"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-hook-form": "^7.71.1",
|
||||||
"react-oidc-context": "^3.3.0",
|
"react-oidc-context": "^3.3.0",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^6.28.2",
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
@@ -47,6 +46,7 @@
|
|||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
|
|||||||
@@ -60,10 +60,5 @@ export const router = createBrowserRouter(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
|
// React Router v7 플래그는 Provider에서 적용합니다.
|
||||||
{
|
|
||||||
future: {
|
|
||||||
v7_startTransition: true,
|
|
||||||
},
|
|
||||||
} as unknown as Parameters<typeof createBrowserRouter>[1],
|
|
||||||
);
|
);
|
||||||
|
|||||||
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 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 "sonner";
|
|
||||||
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 "sonner";
|
|
||||||
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 "sonner";
|
|
||||||
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 "sonner";
|
|
||||||
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 "sonner";
|
|
||||||
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";
|
||||||
|
|
||||||
|
|||||||
@@ -23,20 +23,20 @@ function TenantUsersPage() {
|
|||||||
const params = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
const tenantId = params.tenantId ?? "";
|
const tenantId = params.tenantId ?? "";
|
||||||
|
|
||||||
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
|
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
|
||||||
const tenantQuery = useQuery({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenant", tenantId],
|
queryKey: ["tenant", tenantId],
|
||||||
queryFn: () => fetchTenant(tenantId),
|
queryFn: () => fetchTenant(tenantId),
|
||||||
enabled: tenantId.length > 0,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const companyCode = tenantQuery.data?.slug;
|
const tenantSlug = tenantQuery.data?.slug;
|
||||||
|
|
||||||
// 해당 슬러그로 사용자 검색
|
// 해당 슬러그로 사용자 검색
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ["users", { companyCode }],
|
queryKey: ["users", { tenantSlug }],
|
||||||
queryFn: () => fetchUsers(100, 0, companyCode),
|
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
||||||
enabled: !!companyCode,
|
enabled: !!tenantSlug,
|
||||||
});
|
});
|
||||||
|
|
||||||
const users = usersQuery.data?.items ?? [];
|
const users = usersQuery.data?.items ?? [];
|
||||||
|
|||||||
@@ -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 "sonner";
|
|
||||||
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,
|
||||||
@@ -266,7 +266,7 @@ const MemberTable: React.FC<{
|
|||||||
<Users size={40} />
|
<Users size={40} />
|
||||||
<p>{t("msg.admin.users.list.empty", "멤버가 없습니다.")}</p>
|
<p>{t("msg.admin.users.list.empty", "멤버가 없습니다.")}</p>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
@@ -290,7 +290,7 @@ const MemberTable: React.FC<{
|
|||||||
{showTenant && (
|
{showTenant && (
|
||||||
<TableCell className="py-2">
|
<TableCell className="py-2">
|
||||||
<Badge variant="outline" className="text-[10px] h-5">
|
<Badge variant="outline" className="text-[10px] h-5">
|
||||||
{user.companyCode}
|
{user.tenantSlug}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
@@ -360,7 +360,7 @@ const UserAddDialog: React.FC<{
|
|||||||
const res = await createUser({
|
const res = await createUser({
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
companyCode: tenantSlug,
|
tenantSlug: tenantSlug,
|
||||||
role: "user",
|
role: "user",
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -369,7 +369,6 @@ const UserAddDialog: React.FC<{
|
|||||||
description: res.initialPassword
|
description: res.initialPassword
|
||||||
? `초기 비밀번호: ${res.initialPassword}`
|
? `초기 비밀번호: ${res.initialPassword}`
|
||||||
: undefined,
|
: undefined,
|
||||||
duration: 10000,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -395,7 +394,7 @@ const UserAddDialog: React.FC<{
|
|||||||
if (!selectedUserId) return;
|
if (!selectedUserId) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await updateUser(selectedUserId, { companyCode: tenantSlug });
|
await updateUser(selectedUserId, { tenantSlug: tenantSlug });
|
||||||
toast.success(
|
toast.success(
|
||||||
t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."),
|
t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."),
|
||||||
);
|
);
|
||||||
@@ -505,12 +504,12 @@ const UserAddDialog: React.FC<{
|
|||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{user.email}
|
{user.email}
|
||||||
</div>
|
</div>
|
||||||
{user.companyCode && (
|
{user.tenantSlug && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] mt-1"
|
className="text-[10px] mt-1"
|
||||||
>
|
>
|
||||||
{user.companyCode}
|
{user.tenantSlug}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -776,7 +775,10 @@ 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.tenantId || tenantId;
|
const baseTenantId =
|
||||||
|
(node as unknown as { tenantId?: string }).tenantId ||
|
||||||
|
node.parentId ||
|
||||||
|
"";
|
||||||
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
|
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
|
||||||
} else {
|
} else {
|
||||||
navigate(`/tenants/${node.id}`);
|
navigate(`/tenants/${node.id}`);
|
||||||
@@ -867,6 +869,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 "sonner";
|
|
||||||
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,
|
||||||
@@ -189,7 +189,7 @@ export function UserGroupDetailPage() {
|
|||||||
<p>
|
<p>
|
||||||
Error:{" "}
|
Error:{" "}
|
||||||
{(error as AxiosError<{ error?: string }>)?.response?.data?.error ||
|
{(error as AxiosError<{ error?: string }>)?.response?.data?.error ||
|
||||||
error.message ||
|
(error instanceof Error ? error.message : String(error)) ||
|
||||||
"Not found"}
|
"Not found"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type UserCreateRequest,
|
type UserCreateRequest,
|
||||||
type UserCreateResponse,
|
type UserCreateResponse,
|
||||||
createUser,
|
createUser,
|
||||||
|
fetchMe,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
@@ -68,7 +69,7 @@ function UserCreatePage() {
|
|||||||
name: "",
|
name: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
companyCode: "",
|
tenantSlug: "",
|
||||||
department: "",
|
department: "",
|
||||||
position: "",
|
position: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
@@ -78,13 +79,13 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
// Lock company for tenant_admin
|
// Lock company for tenant_admin
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (profile?.role === "tenant_admin" && profile.companyCode) {
|
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
|
||||||
setValue("companyCode", profile.companyCode);
|
setValue("tenantSlug", profile.tenantSlug);
|
||||||
}
|
}
|
||||||
}, [profile, setValue]);
|
}, [profile, setValue]);
|
||||||
|
|
||||||
const selectedCompanyCode = watch("companyCode");
|
const selectedTenantSlug = watch("tenantSlug");
|
||||||
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
const selectedTenant = tenants.find((t) => t.slug === selectedTenantSlug);
|
||||||
|
|
||||||
const selectedTenantId = selectedTenant?.id ?? "";
|
const selectedTenantId = selectedTenant?.id ?? "";
|
||||||
|
|
||||||
@@ -351,15 +352,15 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="companyCode">
|
<Label htmlFor="tenantSlug">
|
||||||
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
|
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<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"
|
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"}
|
disabled={profile?.role === "tenant_admin"}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
|
|||||||
@@ -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 "sonner";
|
|
||||||
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,
|
||||||
@@ -147,9 +147,21 @@ function TenantProfileCard({
|
|||||||
: false,
|
: 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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +214,7 @@ function UserDetailPage() {
|
|||||||
phone: "",
|
phone: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
status: "active",
|
status: "active",
|
||||||
companyCode: "",
|
tenantSlug: "",
|
||||||
department: "",
|
department: "",
|
||||||
position: "",
|
position: "",
|
||||||
jobTitle: "",
|
jobTitle: "",
|
||||||
@@ -243,12 +255,15 @@ function UserDetailPage() {
|
|||||||
phone: user.phone || "",
|
phone: user.phone || "",
|
||||||
role: user.role,
|
role: user.role,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
companyCode: user.companyCode || "",
|
tenantSlug: user.tenantSlug || "",
|
||||||
department: user.department || "",
|
department: user.department || "",
|
||||||
position: user.position || "",
|
position: user.position || "",
|
||||||
jobTitle: user.jobTitle || "",
|
jobTitle: user.jobTitle || "",
|
||||||
password: "",
|
password: "",
|
||||||
metadata: user.metadata || {},
|
metadata: (user.metadata || {}) as unknown as Record<
|
||||||
|
string,
|
||||||
|
Record<string, unknown>
|
||||||
|
>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [user, reset]);
|
}, [user, reset]);
|
||||||
@@ -377,7 +392,7 @@ function UserDetailPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<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"
|
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={
|
disabled={
|
||||||
profile?.role === "tenant_admin" &&
|
profile?.role === "tenant_admin" &&
|
||||||
userAffiliatedTenants.length <= 1
|
userAffiliatedTenants.length <= 1
|
||||||
@@ -428,7 +443,7 @@ function UserDetailPage() {
|
|||||||
to={`/tenants/${jt.id}`}
|
to={`/tenants/${jt.id}`}
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
|
||||||
jt.id ===
|
jt.id ===
|
||||||
tenants.find((t) => t.slug === watch("companyCode"))
|
tenants.find((t) => t.slug === watch("tenantSlug"))
|
||||||
?.id
|
?.id
|
||||||
? "bg-primary/10 border-primary/30 text-primary font-bold"
|
? "bg-primary/10 border-primary/30 text-primary font-bold"
|
||||||
: "bg-background border-border text-muted-foreground hover:border-primary/50"
|
: "bg-background border-border text-muted-foreground hover:border-primary/50"
|
||||||
|
|||||||
@@ -41,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,
|
||||||
@@ -87,8 +88,8 @@ function UserListPage() {
|
|||||||
|
|
||||||
// Lock company for tenant_admin
|
// Lock company for tenant_admin
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (profile?.role === "tenant_admin" && profile.companyCode) {
|
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
|
||||||
setSelectedCompany(profile.companyCode);
|
setSelectedCompany(profile.tenantSlug);
|
||||||
}
|
}
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
@@ -131,10 +132,7 @@ function UserListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: [
|
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
|
||||||
"users",
|
|
||||||
{ limit, offset, search, companyCode: selectedCompany },
|
|
||||||
],
|
|
||||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
@@ -530,7 +528,7 @@ function UserListPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col text-sm">
|
<div className="flex flex-col text-sm">
|
||||||
<span className="font-medium text-blue-600">
|
<span className="font-medium text-blue-600">
|
||||||
{user.tenant?.name || user.companyCode || "-"}
|
{user.tenant?.name || user.tenantSlug || "-"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{user.department || "-"}
|
{user.department || "-"}
|
||||||
|
|||||||
@@ -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 "sonner";
|
|
||||||
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,
|
||||||
@@ -138,7 +138,7 @@ export function UserBulkMoveGroupModal({
|
|||||||
if (!selectedTenantSlug) return;
|
if (!selectedTenantSlug) return;
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
userIds,
|
userIds,
|
||||||
companyCode: selectedTenantSlug,
|
tenantSlug: selectedTenantSlug,
|
||||||
department: selectedGroupName, // can be empty for "No Department"
|
department: selectedGroupName, // can be empty for "No Department"
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadTemplate = () => {
|
const downloadTemplate = () => {
|
||||||
const headers = "email,name,phone,role,companyCode,department,employee_id";
|
const headers = "email,name,phone,role,tenant,department,employee_id";
|
||||||
const example =
|
const example =
|
||||||
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
@@ -203,7 +203,7 @@ ${example}`,
|
|||||||
<tr key={u.email} className="border-t">
|
<tr key={u.email} className="border-t">
|
||||||
<td className="p-2">{u.email}</td>
|
<td className="p-2">{u.email}</td>
|
||||||
<td className="p-2">{u.name}</td>
|
<td className="p-2">{u.name}</td>
|
||||||
<td className="p-2">{u.companyCode || "-"}</td>
|
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{previewData.length > 10 && (
|
{previewData.length > 10 && (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { parseUserCSV } from "./csvParser";
|
|||||||
|
|
||||||
describe("parseUserCSV", () => {
|
describe("parseUserCSV", () => {
|
||||||
it("should parse valid CSV correctly", () => {
|
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
|
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
|
||||||
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
|
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",
|
name: "Hong Gil Dong",
|
||||||
phone: "010-1111-2222",
|
phone: "010-1111-2222",
|
||||||
role: "user",
|
role: "user",
|
||||||
companyCode: "baron",
|
tenantSlug: "baron",
|
||||||
department: "HR",
|
department: "HR",
|
||||||
metadata: {
|
metadata: {
|
||||||
emp_id: "E001",
|
emp_id: "E001",
|
||||||
@@ -37,10 +37,10 @@ no-name@test.com,`;
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle mixed case headers", () => {
|
it("should handle mixed case headers", () => {
|
||||||
const csv = `EMAIL,Name,CompanyCode
|
const csv = `EMAIL,Name,Tenant
|
||||||
test@test.com,Test,baron`;
|
test@test.com,Test,baron`;
|
||||||
const result = parseUserCSV(csv);
|
const result = parseUserCSV(csv);
|
||||||
expect(result[0].email).toBe("test@test.com");
|
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;
|
item.phone = value;
|
||||||
} else if (header === "role") {
|
} else if (header === "role") {
|
||||||
item.role = value;
|
item.role = value;
|
||||||
} else if (header === "companycode") {
|
} else if (header === "tenant") {
|
||||||
item.companyCode = value;
|
item.tenantSlug = value;
|
||||||
} else if (header === "department") {
|
} else if (header === "department") {
|
||||||
item.department = value;
|
item.department = value;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export type UserSummary = {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
role: string;
|
role: string;
|
||||||
status: string;
|
status: string;
|
||||||
companyCode?: string;
|
tenantSlug?: string;
|
||||||
tenant?: TenantSummary;
|
tenant?: TenantSummary;
|
||||||
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
@@ -376,10 +376,11 @@ export type UserCreateRequest = {
|
|||||||
name: string;
|
name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
companyCode?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserCreateResponse = UserSummary & {
|
export type UserCreateResponse = UserSummary & {
|
||||||
@@ -392,10 +393,11 @@ export type UserUpdateRequest = {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
companyCode?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BulkUserItem = {
|
export type BulkUserItem = {
|
||||||
@@ -403,9 +405,9 @@ export type BulkUserItem = {
|
|||||||
name: string;
|
name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
companyCode?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BulkUserResult = {
|
export type BulkUserResult = {
|
||||||
@@ -423,10 +425,10 @@ export async function fetchUsers(
|
|||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
search?: string,
|
search?: string,
|
||||||
companyCode?: string,
|
tenantSlug?: string,
|
||||||
) {
|
) {
|
||||||
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||||
params: { limit, offset, search, companyCode },
|
params: { limit, offset, search, tenantSlug },
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -446,10 +448,10 @@ export async function createUser(payload: UserCreateRequest) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportUsersCSVUrl(search?: string, companyCode?: string) {
|
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (search) params.append("search", search);
|
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
|
// Get mock role from storage if exists for dev environment
|
||||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||||
@@ -471,6 +473,8 @@ export async function bulkUpdateUsers(payload: {
|
|||||||
userIds: string[];
|
userIds: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
department?: string;
|
||||||
}) {
|
}) {
|
||||||
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||||
return data;
|
return data;
|
||||||
@@ -503,7 +507,7 @@ export type UserProfileResponse = {
|
|||||||
role: string;
|
role: string;
|
||||||
department: string;
|
department: string;
|
||||||
affiliationType: string;
|
affiliationType: string;
|
||||||
companyCode?: string;
|
tenantSlug?: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
tenant?: TenantSummary;
|
tenant?: TenantSummary;
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ describe("i18n utility", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns fallback if key not found", () => {
|
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", () => {
|
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", () => {
|
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",
|
"Hello World",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { Toaster } from "./components/ui/toaster";
|
||||||
import { oidcConfig } from "./lib/auth";
|
import { oidcConfig } from "./lib/auth";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -18,7 +19,13 @@ createRoot(rootElement).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthProvider {...oidcConfig}>
|
<AuthProvider {...oidcConfig}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider
|
||||||
|
router={router}
|
||||||
|
future={{
|
||||||
|
v7_startTransition: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Toaster />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ test.describe("User Schema Dynamic Form", () => {
|
|||||||
id: "u-1",
|
id: "u-1",
|
||||||
name: "John Doe",
|
name: "John Doe",
|
||||||
email: "john@test.com",
|
email: "john@test.com",
|
||||||
companyCode: "test-tenant",
|
tenantSlug: "test-tenant",
|
||||||
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||||
joinedTenants: [
|
joinedTenants: [
|
||||||
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -410,13 +412,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type bulkUserItem struct {
|
type bulkUserItem struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type bulkUserResult struct {
|
type bulkUserResult struct {
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,13 +122,13 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"email": "user1@test.com",
|
"email": "user1@test.com",
|
||||||
"name": "User One",
|
"name": "User One",
|
||||||
"companyCode": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{"emp_id": "E001"},
|
"metadata": map[string]interface{}{"emp_id": "E001"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"email": "user2@test.com",
|
"email": "user2@test.com",
|
||||||
"name": "User Two",
|
"name": "User Two",
|
||||||
"companyCode": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{"emp_id": "E002"},
|
"metadata": map[string]interface{}{"emp_id": "E002"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -155,9 +155,9 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"email": "fail@test.com",
|
"email": "fail@test.com",
|
||||||
"name": "Fail User",
|
"name": "Fail User",
|
||||||
"companyCode": "wrong-tenant",
|
"tenantSlug": "wrong-tenant",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -188,10 +188,10 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"email": "missing-meta@test.com",
|
"email": "missing-meta@test.com",
|
||||||
"name": "No Meta",
|
"name": "No Meta",
|
||||||
"companyCode": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{}, // emp_id missing
|
"metadata": map[string]interface{}{}, // emp_id missing
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -222,10 +222,10 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
|
|||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"users": []map[string]interface{}{
|
"users": []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"email": "regex-fail@test.com",
|
"email": "regex-fail@test.com",
|
||||||
"name": "Regex Fail",
|
"name": "Regex Fail",
|
||||||
"companyCode": "test-tenant",
|
"tenantSlug": "test-tenant",
|
||||||
"metadata": map[string]interface{}{"emp_id": "abc"}, // Should start with E and 3 digits
|
"metadata": map[string]interface{}{"emp_id": "abc"}, // Should start with E and 3 digits
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1760,3 +1760,7 @@ verify = "Verification"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Go to sign-in"
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
files.push(path.join(dirPath, entry.name));
|
files.push(path.join(dirPath, entry.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ function walkDir(dirPath, files) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
files.push(path.join(dirPath, entry.name));
|
files.push(path.join(dirPath, entry.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user