1
0
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:
2026-03-24 17:18:05 +09:00
34 changed files with 3068 additions and 2859 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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],
); );

View 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>
);
}

View 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;
};

View File

@@ -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";

View File

@@ -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>
)) ))

View File

@@ -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,

View File

@@ -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)")}

View File

@@ -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";

View File

@@ -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 ?? [];

View File

@@ -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 ?? [];

View File

@@ -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>

View File

@@ -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="">

View File

@@ -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"

View File

@@ -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 || "-"}

View File

@@ -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"
}); });
}; };

View File

@@ -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 && (

View File

@@ -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");
}); });
}); });

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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",
); );
}); });

View File

@@ -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>,

View File

@@ -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" },

View File

@@ -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

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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
}
}
}
} }
} }

View File

@@ -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
}, },
}, },
} }

View File

@@ -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."

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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));
} }
} }

View File

@@ -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));
} }
} }