1
0
forked from baron/baron-sso
This commit is contained in:
2026-03-24 14:22:05 +09:00
parent b0e18cc724
commit a4f283e4e6
18 changed files with 197 additions and 93 deletions

View File

@@ -30,20 +30,26 @@ const toastBase = (message: string, type: ToastType = "success") => {
export const toast = Object.assign(toastBase, { export const toast = Object.assign(toastBase, {
success: (message: string, options?: { description?: string }) => { success: (message: string, options?: { description?: string }) => {
const finalMessage = options?.description ? `${message} const finalMessage = options?.description
${options.description}` : message; ? `${message}
${options.description}`
: message;
toastBase(finalMessage, "success"); toastBase(finalMessage, "success");
}, },
error: (message: string, options?: { description?: string }) => { error: (message: string, options?: { description?: string }) => {
const finalMessage = options?.description ? `${message} const finalMessage = options?.description
${options.description}` : message; ? `${message}
${options.description}`
: message;
toastBase(finalMessage, "error"); toastBase(finalMessage, "error");
}, },
info: (message: string, options?: { description?: string }) => { info: (message: string, options?: { description?: string }) => {
const finalMessage = options?.description ? `${message} const finalMessage = options?.description
${options.description}` : message; ? `${message}
${options.description}`
: message;
toastBase(finalMessage, "info"); toastBase(finalMessage, "info");
} },
}); });
export const useToastState = () => { export const useToastState = () => {

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 "../../../components/ui/use-toast";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Dialog, Dialog,
@@ -13,6 +12,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { toast } from "../../../components/ui/use-toast";
import { importOrgChart } from "../../../lib/adminApi"; import { importOrgChart } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";

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

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 "../../../components/ui/use-toast";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -38,6 +37,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import { import {
type GroupSummary, type GroupSummary,
addGroupMember, addGroupMember,

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

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 "../../../components/ui/use-toast";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Card, Card,
@@ -14,6 +13,7 @@ import {
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";

View File

@@ -35,7 +35,7 @@ function TenantUsersPage() {
// 해당 슬러그로 사용자 검색 // 해당 슬러그로 사용자 검색
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: ["users", { tenantSlug }], queryKey: ["users", { tenantSlug }],
queryFn: () => fetchUsers(100, 0, tenantSlug), queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
enabled: !!tenantSlug, enabled: !!tenantSlug,
}); });

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 "../../../components/ui/use-toast";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -55,6 +54,7 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "../../../components/ui/tabs"; } from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast";
import { import {
type GroupSummary, type GroupSummary,
type TenantSummary, type TenantSummary,
@@ -775,7 +775,8 @@ const TenantTreeRow: React.FC<{
onClick={() => { onClick={() => {
if (node.type === "USER_GROUP") { if (node.type === "USER_GROUP") {
// User groups have a different detail path // User groups have a different detail path
const baseTenantId = (node as any).tenantId || (node as any).parentId || ""; const baseTenantId =
(node as any).tenantId || (node as any).parentId || "";
navigate(`/tenants/${baseTenantId}/organization/${node.id}`); navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
} else { } else {
navigate(`/tenants/${node.id}`); navigate(`/tenants/${node.id}`);
@@ -866,6 +867,11 @@ function TenantUserGroupsTab() {
toast.success(t("msg.info.saved_success", "저장되었습니다.")); toast.success(t("msg.info.saved_success", "저장되었습니다."));
setIsAddDialogOpen(false); setIsAddDialogOpen(false);
}, },
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
}); });
const allTenants = data?.items ?? []; const allTenants = data?.items ?? [];

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 "../../../components/ui/use-toast";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -39,6 +38,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import { import {
addGroupMember, addGroupMember,
assignGroupRole, assignGroupRole,

View File

@@ -18,9 +18,9 @@ import {
type UserCreateRequest, type UserCreateRequest,
type UserCreateResponse, type UserCreateResponse,
createUser, createUser,
fetchMe,
fetchTenant, fetchTenant,
fetchTenants, fetchTenants,
fetchMe,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";

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 "../../components/ui/use-toast";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Card, Card,
@@ -30,6 +29,7 @@ import {
} from "../../components/ui/card"; } from "../../components/ui/card";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import { toast } from "../../components/ui/use-toast";
import { import {
type TenantSummary, type TenantSummary,
type UserUpdateRequest, type UserUpdateRequest,

View File

@@ -1,4 +1,3 @@
import { toast } from "../../components/ui/use-toast";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
@@ -42,6 +41,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import { import {
bulkDeleteUsers, bulkDeleteUsers,
bulkUpdateUsers, bulkUpdateUsers,
@@ -132,10 +132,7 @@ function UserListPage() {
}; };
const query = useQuery({ const query = useQuery({
queryKey: [ queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
"users",
{ limit, offset, search, tenantSlug: selectedCompany },
],
queryFn: () => fetchUsers(limit, offset, search, selectedCompany), queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });

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 "../../../components/ui/use-toast";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Dialog, Dialog,
@@ -15,6 +14,7 @@ import {
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area"; import { ScrollArea } from "../../../components/ui/scroll-area";
import { toast } from "../../../components/ui/use-toast";
import { import {
type GroupSummary, type GroupSummary,
type TenantSummary, type TenantSummary,

View File

@@ -405,7 +405,7 @@ export type BulkUserItem = {
role?: string; role?: string;
tenantSlug?: string; tenantSlug?: string;
department?: string; department?: string;
metadata?: Record<string, unknown>; metadata: Record<string, string>;
}; };
export type BulkUserResult = { export type BulkUserResult = {

View File

@@ -5,8 +5,8 @@ import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient"; import { queryClient } from "./app/queryClient";
import { router } from "./app/routes"; import { router } from "./app/routes";
import { oidcConfig } from "./lib/auth";
import { Toaster } from "./components/ui/toaster"; import { Toaster } from "./components/ui/toaster";
import { oidcConfig } from "./lib/auth";
import "./index.css"; import "./index.css";
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");

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")
} }
@@ -414,7 +416,7 @@ type bulkUserItem struct {
Name string `json:"name"` Name string `json:"name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Role string `json:"role"` Role string `json:"role"`
CompanyCode string `json:"companyCode"` TenantSlug string `json:"tenantSlug"`
Department string `json:"department"` Department string `json:"department"`
Metadata map[string]any `json:"metadata"` Metadata map[string]any `json:"metadata"`
} }
@@ -456,13 +458,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
type tenantCacheItem struct { type tenantCacheItem struct {
ID string ID string
Schema []interface{} Schema []interface{}
Groups []domain.UserGroup
} }
tenantCache := make(map[string]tenantCacheItem) tenantCache := make(map[string]tenantCacheItem)
for _, item := range req.Users { for _, item := range req.Users {
email := strings.TrimSpace(item.Email) email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
compCode := strings.TrimSpace(item.CompanyCode) tenantSlug := strings.TrimSpace(item.TenantSlug)
dept := strings.TrimSpace(item.Department) dept := strings.TrimSpace(item.Department)
if email == "" || name == "" { if email == "" || name == "" {
@@ -470,14 +473,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue continue
} }
if compCode == "" { if tenantSlug == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "companyCode (tenant) is required"}) results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantSlug is required"})
continue continue
} }
// Role-based access check // Role-based access check
if requester != nil && requester.Role == domain.RoleTenantAdmin { if requester != nil && requester.Role == domain.RoleTenantAdmin {
if compCode != requester.CompanyCode { if tenantSlug != requester.CompanyCode {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"}) results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
continue continue
} }
@@ -486,18 +489,25 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
// Verify Tenant Existence and Resolve ID (with Cache) // Verify Tenant Existence and Resolve ID (with Cache)
var tItem tenantCacheItem var tItem tenantCacheItem
var exists bool var exists bool
if tItem, exists = tenantCache[compCode]; !exists { if tItem, exists = tenantCache[tenantSlug]; !exists {
if h.TenantService != nil { if h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode) tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
if err != nil || tenant == nil { if err != nil || tenant == nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid companyCode: tenant not found"}) results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid tenantSlug: tenant not found"})
continue continue
} }
tItem.ID = tenant.ID tItem.ID = tenant.ID
if s, ok := tenant.Config["userSchema"].([]interface{}); ok { if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
tItem.Schema = s tItem.Schema = s
} }
tenantCache[compCode] = tItem // [Fix] Cache user groups for this tenant to match department
if h.UserGroupRepo != nil {
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
tItem.Groups = groups
}
}
tenantCache[tenantSlug] = tItem
} else { } else {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"}) results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"})
continue continue
@@ -521,7 +531,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
attributes := map[string]interface{}{ attributes := map[string]interface{}{
"department": dept, "department": dept,
"affiliationType": "internal", "affiliationType": "internal",
"companyCode": compCode, "companyCode": tenantSlug,
"tenant_id": tItem.ID, "tenant_id": tItem.ID,
"grade": role, "grade": role,
"role": role, "role": role,
@@ -541,8 +551,18 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Attributes: attributes, Attributes: attributes,
}, password) }, password)
if err != nil { if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()}) // 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도
continue if strings.Contains(err.Error(), "already exists") {
identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email)
if err != nil || identityID == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "이미 존재하는 사용자지만 ID를 찾을 수 없습니다."})
continue
}
slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID)
} else {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
}
} }
// [CRITICAL FIX] Sync to local DB directly using current data // [CRITICAL FIX] Sync to local DB directly using current data
@@ -555,7 +575,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Phone: normalizePhoneNumber(item.Phone), Phone: normalizePhoneNumber(item.Phone),
Role: role, Role: role,
Status: "active", Status: "active",
CompanyCode: compCode, CompanyCode: tenantSlug,
Department: dept, Department: dept,
AffiliationType: "internal", AffiliationType: "internal",
CreatedAt: time.Now(), CreatedAt: time.Now(),
@@ -590,6 +610,22 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
} }
// 3. Sync membership to UserGroup if department matches
if dept != "" {
for _, g := range tItem.Groups {
if strings.EqualFold(strings.TrimSpace(g.Name), dept) {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: g.ID,
Relation: "members",
Subject: "User:" + localUser.ID,
Action: domain.KetoOutboxActionCreate,
})
break
}
}
}
} }
} }