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-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"
},
@@ -39,6 +38,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
@@ -2659,6 +2659,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/history": {
"version": "4.7.11",
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.10.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
@@ -2689,6 +2696,29 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-router": {
"version": "5.1.20",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/history": "^4.7.11",
"@types/react": "*"
}
},
"node_modules/@types/react-router-dom": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/history": "^4.7.11",
"@types/react": "*",
"@types/react-router": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
@@ -5156,16 +5186,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -34,7 +34,6 @@
"react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"
},
@@ -47,6 +46,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",

View File

@@ -60,10 +60,5 @@ export const router = createBrowserRouter(
],
},
],
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
{
future: {
v7_startTransition: true,
},
} as unknown as Parameters<typeof createBrowserRouter>[1],
// React Router v7 플래그는 Provider에서 적용합니다.
);

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 { Download, FileText, Loader2, Upload } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Dialog,
@@ -13,6 +12,7 @@ import {
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { toast } from "../../../components/ui/use-toast";
import { importOrgChart } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";

View File

@@ -12,7 +12,6 @@ import {
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -39,6 +38,7 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addTenantAdmin,
addTenantOwner,
@@ -291,25 +291,29 @@ export function TenantAdminsAndOwnersTab() {
{owner.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
owner.id === currentUserId ||
currentOwners.length <= 1
? "opacity-50 cursor-not-allowed"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveOwner(owner.id, owner.name)
}
disabled={
removeOwnerMutation.isPending ||
owner.id === currentUserId ||
currentOwners.length <= 1
}
title={
owner.id === currentUserId
<span className="relative inline-block group/tt">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
owner.id === currentUserId ||
currentOwners.length <= 1
? "opacity-50 cursor-not-allowed pointer-events-none"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveOwner(owner.id, owner.name)
}
disabled={
removeOwnerMutation.isPending ||
owner.id === currentUserId ||
currentOwners.length <= 1
}
>
<Trash2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
{owner.id === currentUserId
? t(
"msg.admin.tenants.owners.remove_self",
"본인의 권한은 회수할 수 없습니다.",
@@ -322,11 +326,9 @@ export function TenantAdminsAndOwnersTab() {
: t(
"ui.admin.tenants.owners.remove_title",
"소유자 권한 회수",
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</span>
</span>
</TableCell>
</TableRow>
))
@@ -420,25 +422,29 @@ export function TenantAdminsAndOwnersTab() {
{admin.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
admin.id === currentUserId ||
currentAdmins.length <= 1
? "opacity-50 cursor-not-allowed"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={
removeAdminMutation.isPending ||
admin.id === currentUserId ||
currentAdmins.length <= 1
}
title={
admin.id === currentUserId
<span className="relative inline-block group/tt">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
admin.id === currentUserId ||
currentAdmins.length <= 1
? "opacity-50 cursor-not-allowed pointer-events-none"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={
removeAdminMutation.isPending ||
admin.id === currentUserId ||
currentAdmins.length <= 1
}
>
<Trash2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
{admin.id === currentUserId
? t(
"msg.admin.tenants.admins.remove_self",
"본인의 권한은 회수할 수 없습니다.",
@@ -451,11 +457,9 @@ export function TenantAdminsAndOwnersTab() {
: t(
"ui.admin.tenants.admins.remove_title",
"관리자 권한 회수",
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</span>
</span>
</TableCell>
</TableRow>
))

View File

@@ -18,7 +18,6 @@ import {
import type React from "react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -38,6 +37,7 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
addGroupMember,

View File

@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
import { Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -15,10 +14,12 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { toast } from "../../../components/ui/use-toast";
import {
approveTenant,
deleteTenant,
fetchTenant,
fetchTenants,
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -39,12 +40,21 @@ export function TenantProfilePage() {
queryFn: () => fetchTenant(tenantId),
});
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchTenants(1000, 0),
});
const availableParents =
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [parentId, setParentId] = useState("");
useEffect(() => {
if (tenantQuery.data) {
@@ -54,6 +64,7 @@ export function TenantProfilePage() {
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
setParentId(tenantQuery.data.parentId ?? "");
}
}, [tenantQuery.data]);
@@ -65,6 +76,7 @@ export function TenantProfilePage() {
slug,
description: description || undefined,
status,
parentId: parentId || undefined,
domains: domains
.split(",")
.map((d) => d.trim())
@@ -197,6 +209,31 @@ export function TenantProfilePage() {
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
{availableParents.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug})
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}

View File

@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
import { Plus, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -14,6 +13,7 @@ import {
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";

View File

@@ -23,20 +23,20 @@ function TenantUsersPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const companyCode = tenantQuery.data?.slug;
const tenantSlug = tenantQuery.data?.slug;
// 해당 슬러그로 사용자 검색
const usersQuery = useQuery({
queryKey: ["users", { companyCode }],
queryFn: () => fetchUsers(100, 0, companyCode),
enabled: !!companyCode,
queryKey: ["users", { tenantSlug }],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
enabled: !!tenantSlug,
});
const users = usersQuery.data?.items ?? [];

View File

@@ -20,7 +20,6 @@ import {
import * as React from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -55,6 +54,7 @@ import {
TabsList,
TabsTrigger,
} from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
type TenantSummary,
@@ -266,7 +266,7 @@ const MemberTable: React.FC<{
<Users size={40} />
<p>{t("msg.admin.users.list.empty", "멤버가 없습니다.")}</p>
<Button
variant="link"
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-2"
@@ -290,7 +290,7 @@ const MemberTable: React.FC<{
{showTenant && (
<TableCell className="py-2">
<Badge variant="outline" className="text-[10px] h-5">
{user.companyCode}
{user.tenantSlug}
</Badge>
</TableCell>
)}
@@ -360,7 +360,7 @@ const UserAddDialog: React.FC<{
const res = await createUser({
email,
name,
companyCode: tenantSlug,
tenantSlug: tenantSlug,
role: "user",
});
toast.success(
@@ -369,7 +369,6 @@ const UserAddDialog: React.FC<{
description: res.initialPassword
? `초기 비밀번호: ${res.initialPassword}`
: undefined,
duration: 10000,
},
);
@@ -395,7 +394,7 @@ const UserAddDialog: React.FC<{
if (!selectedUserId) return;
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { companyCode: tenantSlug });
await updateUser(selectedUserId, { tenantSlug: tenantSlug });
toast.success(
t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."),
);
@@ -505,12 +504,12 @@ const UserAddDialog: React.FC<{
<div className="text-xs text-muted-foreground">
{user.email}
</div>
{user.companyCode && (
{user.tenantSlug && (
<Badge
variant="outline"
className="text-[10px] mt-1"
>
{user.companyCode}
{user.tenantSlug}
</Badge>
)}
</div>
@@ -776,7 +775,10 @@ const TenantTreeRow: React.FC<{
onClick={() => {
if (node.type === "USER_GROUP") {
// User groups have a different detail path
const baseTenantId = node.tenantId || tenantId;
const baseTenantId =
(node as unknown as { tenantId?: string }).tenantId ||
node.parentId ||
"";
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
} else {
navigate(`/tenants/${node.id}`);
@@ -867,6 +869,11 @@ function TenantUserGroupsTab() {
toast.success(t("msg.info.saved_success", "저장되었습니다."));
setIsAddDialogOpen(false);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const allTenants = data?.items ?? [];

View File

@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -39,6 +38,7 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addGroupMember,
assignGroupRole,
@@ -189,7 +189,7 @@ export function UserGroupDetailPage() {
<p>
Error:{" "}
{(error as AxiosError<{ error?: string }>)?.response?.data?.error ||
error.message ||
(error instanceof Error ? error.message : String(error)) ||
"Not found"}
</p>
</div>

View File

@@ -18,6 +18,7 @@ import {
type UserCreateRequest,
type UserCreateResponse,
createUser,
fetchMe,
fetchTenant,
fetchTenants,
} from "../../lib/adminApi";
@@ -68,7 +69,7 @@ function UserCreatePage() {
name: "",
phone: "",
role: "user",
companyCode: "",
tenantSlug: "",
department: "",
position: "",
jobTitle: "",
@@ -78,13 +79,13 @@ function UserCreatePage() {
// Lock company for tenant_admin
React.useEffect(() => {
if (profile?.role === "tenant_admin" && profile.companyCode) {
setValue("companyCode", profile.companyCode);
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
setValue("tenantSlug", profile.tenantSlug);
}
}, [profile, setValue]);
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const selectedTenantSlug = watch("tenantSlug");
const selectedTenant = tenants.find((t) => t.slug === selectedTenantSlug);
const selectedTenantId = selectedTenant?.id ?? "";
@@ -351,15 +352,15 @@ function UserCreatePage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode">
<Label htmlFor="tenantSlug">
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="relative">
<select
id="companyCode"
id="tenantSlug"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
{...register("tenantSlug")}
disabled={profile?.role === "tenant_admin"}
>
<option value="">

View File

@@ -19,7 +19,6 @@ import {
useForm,
} from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -30,6 +29,7 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { toast } from "../../components/ui/use-toast";
import {
type TenantSummary,
type UserUpdateRequest,
@@ -147,9 +147,21 @@ function TenantProfileCard({
: false,
})}
/>
{errors.metadata?.[tenant.id]?.[field.key] && (
{(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key] && (
<p className="text-[10px] text-destructive">
{errors.metadata[tenant.id][field.key].message}
{
(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key]?.message
}
</p>
)}
</div>
@@ -202,7 +214,7 @@ function UserDetailPage() {
phone: "",
role: "user",
status: "active",
companyCode: "",
tenantSlug: "",
department: "",
position: "",
jobTitle: "",
@@ -243,12 +255,15 @@ function UserDetailPage() {
phone: user.phone || "",
role: user.role,
status: user.status,
companyCode: user.companyCode || "",
tenantSlug: user.tenantSlug || "",
department: user.department || "",
position: user.position || "",
jobTitle: user.jobTitle || "",
password: "",
metadata: user.metadata || {},
metadata: (user.metadata || {}) as unknown as Record<
string,
Record<string, unknown>
>,
});
}
}, [user, reset]);
@@ -377,7 +392,7 @@ function UserDetailPage() {
<div className="relative">
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
{...register("companyCode")}
{...register("tenantSlug")}
disabled={
profile?.role === "tenant_admin" &&
userAffiliatedTenants.length <= 1
@@ -428,7 +443,7 @@ function UserDetailPage() {
to={`/tenants/${jt.id}`}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors ${
jt.id ===
tenants.find((t) => t.slug === watch("companyCode"))
tenants.find((t) => t.slug === watch("tenantSlug"))
?.id
? "bg-primary/10 border-primary/30 text-primary font-bold"
: "bg-background border-border text-muted-foreground hover:border-primary/50"

View File

@@ -41,6 +41,7 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import {
bulkDeleteUsers,
bulkUpdateUsers,
@@ -87,8 +88,8 @@ function UserListPage() {
// Lock company for tenant_admin
React.useEffect(() => {
if (profile?.role === "tenant_admin" && profile.companyCode) {
setSelectedCompany(profile.companyCode);
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
setSelectedCompany(profile.tenantSlug);
}
}, [profile]);
@@ -131,10 +132,7 @@ function UserListPage() {
};
const query = useQuery({
queryKey: [
"users",
{ limit, offset, search, companyCode: selectedCompany },
],
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
placeholderData: (previousData) => previousData,
});
@@ -530,7 +528,7 @@ function UserListPage() {
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.companyCode || "-"}
{user.tenant?.name || user.tenantSlug || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}

View File

@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Dialog,
@@ -15,6 +14,7 @@ import {
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
type TenantSummary,
@@ -138,7 +138,7 @@ export function UserBulkMoveGroupModal({
if (!selectedTenantSlug) return;
mutation.mutate({
userIds,
companyCode: selectedTenantSlug,
tenantSlug: selectedTenantSlug,
department: selectedGroupName, // can be empty for "No Department"
});
};

View File

@@ -74,7 +74,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
};
const downloadTemplate = () => {
const headers = "email,name,phone,role,companyCode,department,employee_id";
const headers = "email,name,phone,role,tenant,department,employee_id";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
const blob = new Blob(
@@ -203,7 +203,7 @@ ${example}`,
<tr key={u.email} className="border-t">
<td className="p-2">{u.email}</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.companyCode || "-"}</td>
<td className="p-2">{u.tenantSlug || "-"}</td>
</tr>
))}
{previewData.length > 10 && (

View File

@@ -3,7 +3,7 @@ import { parseUserCSV } from "./csvParser";
describe("parseUserCSV", () => {
it("should parse valid CSV correctly", () => {
const csv = `email,name,phone,role,companyCode,department,emp_id
const csv = `email,name,phone,role,tenant,department,emp_id
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
@@ -15,7 +15,7 @@ user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
name: "Hong Gil Dong",
phone: "010-1111-2222",
role: "user",
companyCode: "baron",
tenantSlug: "baron",
department: "HR",
metadata: {
emp_id: "E001",
@@ -37,10 +37,10 @@ no-name@test.com,`;
});
it("should handle mixed case headers", () => {
const csv = `EMAIL,Name,CompanyCode
const csv = `EMAIL,Name,Tenant
test@test.com,Test,baron`;
const result = parseUserCSV(csv);
expect(result[0].email).toBe("test@test.com");
expect(result[0].companyCode).toBe("baron");
expect(result[0].tenantSlug).toBe("baron");
});
});

View File

@@ -30,8 +30,8 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.phone = value;
} else if (header === "role") {
item.role = value;
} else if (header === "companycode") {
item.companyCode = value;
} else if (header === "tenant") {
item.tenantSlug = value;
} else if (header === "department") {
item.department = value;
} else {

View File

@@ -352,7 +352,7 @@ export type UserSummary = {
phone?: string;
role: string;
status: string;
companyCode?: string;
tenantSlug?: string;
tenant?: TenantSummary;
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
@@ -376,10 +376,11 @@ export type UserCreateRequest = {
name: string;
phone?: string;
role?: string;
companyCode?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type UserCreateResponse = UserSummary & {
@@ -392,10 +393,11 @@ export type UserUpdateRequest = {
phone?: string;
role?: string;
status?: string;
companyCode?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type BulkUserItem = {
@@ -403,9 +405,9 @@ export type BulkUserItem = {
name: string;
phone?: string;
role?: string;
companyCode?: string;
tenantSlug?: string;
department?: string;
metadata?: Record<string, unknown>;
metadata: Record<string, string>;
};
export type BulkUserResult = {
@@ -423,10 +425,10 @@ export async function fetchUsers(
limit = 50,
offset = 0,
search?: string,
companyCode?: string,
tenantSlug?: string,
) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search, companyCode },
params: { limit, offset, search, tenantSlug },
});
return data;
}
@@ -446,10 +448,10 @@ export async function createUser(payload: UserCreateRequest) {
return data;
}
export function exportUsersCSVUrl(search?: string, companyCode?: string) {
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
const params = new URLSearchParams();
if (search) params.append("search", search);
if (companyCode) params.append("companyCode", companyCode);
if (tenantSlug) params.append("tenantSlug", tenantSlug);
// Get mock role from storage if exists for dev environment
const mockRole = window.localStorage.getItem("X-Mock-Role");
@@ -471,6 +473,8 @@ export async function bulkUpdateUsers(payload: {
userIds: string[];
status?: string;
role?: string;
tenantSlug?: string;
department?: string;
}) {
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
return data;
@@ -503,7 +507,7 @@ export type UserProfileResponse = {
role: string;
department: string;
affiliationType: string;
companyCode?: string;
tenantSlug?: string;
tenantId?: string;
metadata?: Record<string, unknown>;
tenant?: TenantSummary;

View File

@@ -8,15 +8,17 @@ describe("i18n utility", () => {
});
it("returns fallback if key not found", () => {
expect(t("non.existent.key", "Fallback")).toBe("Fallback");
expect(t("this.key.truly.does.not.exist", "Fallback")).toBe("Fallback");
});
it("returns key if fallback not provided and key not found", () => {
expect(t("non.existent.key")).toBe("non.existent.key");
expect(t("this.key.truly.does.not.exist")).toBe(
"this.key.truly.does.not.exist",
);
});
it("replaces variables in template", () => {
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe(
expect(t("this.test.key", "Hello {{ name }}", { name: "World" })).toBe(
"Hello World",
);
});

View File

@@ -5,6 +5,7 @@ import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import { Toaster } from "./components/ui/toaster";
import { oidcConfig } from "./lib/auth";
import "./index.css";
@@ -18,7 +19,13 @@ createRoot(rootElement).render(
<StrictMode>
<AuthProvider {...oidcConfig}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
<Toaster />
</QueryClientProvider>
</AuthProvider>
</StrictMode>,

View File

@@ -70,7 +70,7 @@ test.describe("User Schema Dynamic Form", () => {
id: "u-1",
name: "John Doe",
email: "john@test.com",
companyCode: "test-tenant",
tenantSlug: "test-tenant",
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
joinedTenants: [
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },

View File

@@ -18,8 +18,8 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true

View File

@@ -282,7 +282,7 @@ func main() {
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)

View File

@@ -364,6 +364,24 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
if pid == "" {
tenant.ParentID = nil
} else {
// 순환 참조(Circular Dependency) 방지 로직:
// 새로운 부모(pid)부터 상위로 탐색하면서 현재 테넌트(tenant.ID)가 나오면 순환 참조로 간주함
checkID := pid
for checkID != "" {
if checkID == tenant.ID {
return errorJSON(c, fiber.StatusConflict, "순환 참조 오류: 하위 테넌트를 상위 테넌트로 지정할 수 없습니다.")
}
var pTenant domain.Tenant
if err := h.DB.Select("id, parent_id").First(&pTenant, "id = ?", checkID).Error; err != nil {
break // 데이터를 찾을 수 없거나 에러 발생 시 반복문 종료 (추후 외래키 제약조건 등에서 에러 발생)
}
if pTenant.ParentID != nil {
checkID = *pTenant.ParentID
} else {
break
}
}
tenant.ParentID = &pid
}

View File

@@ -34,9 +34,10 @@ type UserHandler struct {
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserGroupRepo repository.UserGroupRepository
}
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository) *UserHandler {
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository) *UserHandler {
return &UserHandler{
KratosAdmin: kratosAdmin,
OryProvider: oryProvider,
@@ -44,6 +45,7 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi
KetoService: ketoService,
KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo,
UserGroupRepo: userGroupRepo,
}
}
@@ -83,7 +85,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
search := strings.TrimSpace(c.Query("search"))
companyCode := strings.TrimSpace(c.Query("companyCode"))
tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
if limit <= 0 {
limit = 50
@@ -125,8 +127,8 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
}
}
// Dedicated companyCode filter
if companyCode != "" && !strings.EqualFold(compCode, companyCode) {
// Dedicated tenantSlug filter
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) {
continue
}
@@ -176,7 +178,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
// Fetch from UserRepo
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode)
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantSlug)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
}
@@ -410,13 +412,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
type bulkUserItem struct {
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Metadata map[string]any `json:"metadata"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
TenantSlug string `json:"tenantSlug"`
Department string `json:"department"`
Metadata map[string]any `json:"metadata"`
}
type bulkUserResult struct {
@@ -456,13 +458,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
type tenantCacheItem struct {
ID string
Schema []interface{}
Groups []domain.UserGroup
}
tenantCache := make(map[string]tenantCacheItem)
for _, item := range req.Users {
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
compCode := strings.TrimSpace(item.CompanyCode)
tenantSlug := strings.TrimSpace(item.TenantSlug)
dept := strings.TrimSpace(item.Department)
if email == "" || name == "" {
@@ -470,14 +473,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue
}
if compCode == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "companyCode (tenant) is required"})
if tenantSlug == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantSlug is required"})
continue
}
// Role-based access check
if requester != nil && requester.Role == domain.RoleTenantAdmin {
if compCode != requester.CompanyCode {
if tenantSlug != requester.CompanyCode {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
continue
}
@@ -486,18 +489,25 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
// Verify Tenant Existence and Resolve ID (with Cache)
var tItem tenantCacheItem
var exists bool
if tItem, exists = tenantCache[compCode]; !exists {
if tItem, exists = tenantCache[tenantSlug]; !exists {
if h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode)
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
if err != nil || tenant == nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid companyCode: tenant not found"})
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid tenantSlug: tenant not found"})
continue
}
tItem.ID = tenant.ID
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
tItem.Schema = s
}
tenantCache[compCode] = tItem
// [Fix] Cache user groups for this tenant to match department
if h.UserGroupRepo != nil {
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
tItem.Groups = groups
}
}
tenantCache[tenantSlug] = tItem
} else {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"})
continue
@@ -521,7 +531,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
attributes := map[string]interface{}{
"department": dept,
"affiliationType": "internal",
"companyCode": compCode,
"companyCode": tenantSlug,
"tenant_id": tItem.ID,
"grade": role,
"role": role,
@@ -541,8 +551,18 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Attributes: attributes,
}, password)
if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
// 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
if strings.Contains(err.Error(), "already exists") {
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email)
if err != nil || identityID == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "이미 존재하는 사용자지만 ID를 찾을 수 없습니다."})
continue
}
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
} else {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
}
}
// [CRITICAL FIX] Sync to local DB directly using current data
@@ -555,7 +575,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Phone: normalizePhoneNumber(item.Phone),
Role: role,
Status: "active",
CompanyCode: compCode,
CompanyCode: tenantSlug,
Department: dept,
AffiliationType: "internal",
CreatedAt: time.Now(),
@@ -590,6 +610,22 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Action: domain.KetoOutboxActionCreate,
})
}
// 3. Sync membership to UserGroup if department matches
if dept != "" {
for _, g := range tItem.Groups {
if strings.EqualFold(strings.TrimSpace(g.Name), dept) {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: g.ID,
Relation: "members",
Subject: "User:" + localUser.ID,
Action: domain.KetoOutboxActionCreate,
})
break
}
}
}
}
}

View File

@@ -122,13 +122,13 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
{
"email": "user1@test.com",
"name": "User One",
"companyCode": "test-tenant",
"tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"emp_id": "E001"},
},
{
"email": "user2@test.com",
"name": "User Two",
"companyCode": "test-tenant",
"tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"emp_id": "E002"},
},
},
@@ -155,9 +155,9 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "fail@test.com",
"name": "Fail User",
"companyCode": "wrong-tenant",
"email": "fail@test.com",
"name": "Fail User",
"tenantSlug": "wrong-tenant",
},
},
}
@@ -188,10 +188,10 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "missing-meta@test.com",
"name": "No Meta",
"companyCode": "test-tenant",
"metadata": map[string]interface{}{}, // emp_id missing
"email": "missing-meta@test.com",
"name": "No Meta",
"tenantSlug": "test-tenant",
"metadata": map[string]interface{}{}, // emp_id missing
},
},
}
@@ -222,10 +222,10 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
payload := map[string]interface{}{
"users": []map[string]interface{}{
{
"email": "regex-fail@test.com",
"name": "Regex Fail",
"companyCode": "test-tenant",
"metadata": map[string]interface{}{"emp_id": "abc"}, // Should start with E and 3 digits
"email": "regex-fail@test.com",
"name": "Regex Fail",
"tenantSlug": "test-tenant",
"metadata": map[string]interface{}{"emp_id": "abc"}, // Should start with E and 3 digits
},
},
}

View File

@@ -1760,3 +1760,7 @@ verify = "Verification"
[ui.userfront.signup.success]
action = "Go to sign-in"
[ui.admin.tenants.profile.form]
parent = "Parent Tenant (Optional)"
parent_help = "Select a parent tenant if this is a subsidiary or sub-organization."

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;
}
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
continue;
}
files.push(path.join(dirPath, entry.name));
}
}

View File

@@ -113,6 +113,10 @@ function walkDir(dirPath, files) {
continue;
}
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) {
continue;
}
files.push(path.join(dirPath, entry.name));
}
}