- {navItems.map(({ label, to, icon: Icon }) => {
+ {navItems.map((item: NavItem) => {
+ const { label, to, icon: Icon, isExternal } = item;
const isOrgChart = location.pathname === "/tenants/org-chart";
const isTenantsRoot = to === "/tenants";
const isCustomActive = isTenantsRoot
@@ -449,6 +460,21 @@ function AppLayout() {
? location.pathname === "/"
: location.pathname.startsWith(to);
+ if (isExternal) {
+ return (
+
+
+ {t(label, label)}
+
+ );
+ }
+
return (
(null);
- const [lines, setLines] = React.useState<
- {
- x1: number;
- y1: number;
- x2: number;
- y2: number;
- key: string;
- path: string;
- }[]
- >([]);
- const [svgSize, setSvgSize] = React.useState({ width: 0, height: 0 });
-
- const tenantsQuery = useQuery({
- queryKey: ["tenants-full-tree-v2"],
- queryFn: () => fetchTenants(10000, 0),
- });
-
- const usersQuery = useQuery({
- queryKey: ["users", { limit: 5000, offset: 0 }],
- queryFn: () => fetchUsers(5000, 0),
- });
-
- const { rootNodes, usersMap } = React.useMemo(() => {
- if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
- return { rootNodes: [], usersMap: new Map() };
- }
-
- const uMap = new Map();
-
- // Process users to map them to multiple tenants if applicable
- for (const u of usersQuery.data.items) {
- if (u.status !== "active") continue;
-
- // Extract all associated tenant slugs
- const slugs = new Set();
-
- const primarySlug =
- u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || "";
- if (primarySlug) {
- slugs.add(primarySlug);
- }
-
- if (u.joinedTenants && Array.isArray(u.joinedTenants)) {
- for (const jt of u.joinedTenants) {
- if (jt.slug) {
- slugs.add(jt.slug.toLowerCase());
- }
- }
- }
-
- // Add user to all matching slugs in the map
- for (const slug of slugs) {
- const list = uMap.get(slug) || [];
- // Prevent duplicate user references in the same list
- if (!list.some((existing) => existing.id === u.id)) {
- list.push(u);
- }
- uMap.set(slug, list);
- }
- }
-
- const allTenants = tenantsQuery.data.items;
- const { subTree: roots } = buildTenantFullTree(allTenants);
-
- return { rootNodes: roots, usersMap: uMap };
- }, [tenantsQuery.data, usersQuery.data]);
-
- const [selectedDept, setSelectedDept] = React.useState("전체");
-
- const depts = React.useMemo(() => {
- return rootNodes.map((n) => n.name).sort();
- }, [rootNodes]);
-
- React.useEffect(() => {
- if (selectedDept !== "전체" && !depts.includes(selectedDept)) {
- setSelectedDept("전체");
- }
- }, [selectedDept, depts]);
-
- const buildHierarchy = (tNode: TenantNode, depth: number): OrgNode => {
- const slug = tNode.slug.toLowerCase();
- const members = usersMap.get(slug) || [];
-
- const children = tNode.children.map((c) => buildHierarchy(c, depth + 1));
-
- // Calculate recursive total users instead of simple tenant count to account for actual mapped members
- let recursiveTotal = members.length;
- for (const child of children) {
- recursiveTotal += child.totalCount || 0;
- }
-
- return {
- id: tNode.id,
- name: tNode.name,
- level: depth,
- members,
- children,
- totalCount: recursiveTotal,
- companyCode: slug,
- type: tNode.type,
- };
- };
-
- const drawLines = React.useCallback(() => {
- if (!containerRef.current) return;
- const container = containerRef.current;
- const rect = container.getBoundingClientRect();
- const scrollTop = container.scrollTop;
- const scrollLeft = container.scrollLeft;
- const childBoxes = container.querySelectorAll("[data-parent]");
- const newLines: {
- x1: number;
- y1: number;
- x2: number;
- y2: number;
- key: string;
- path: string;
- }[] = [];
-
- for (const box of Array.from(childBoxes)) {
- const parentId = box.getAttribute("data-parent");
- if (!parentId) continue;
- const parent = document.getElementById(parentId);
- if (!parent) continue;
-
- const pRect = parent.getBoundingClientRect();
- const cRect = box.getBoundingClientRect();
-
- if (pRect.width === 0 || cRect.width === 0) continue;
-
- const parentLevel = Number.parseInt(
- parent.getAttribute("data-level") || "0",
- 10,
- );
-
- if (parentLevel === 0) {
- // Horizontal fork for Level 0 -> Level 1
- const px = pRect.left + pRect.width / 2 - rect.left + scrollLeft;
- const py = pRect.bottom - rect.top + scrollTop;
- const cx = cRect.left + cRect.width / 2 - rect.left + scrollLeft;
- const cy = cRect.top - rect.top + scrollTop;
- const midY = py + (cy - py) / 2;
-
- newLines.push({
- key: `${parentId}->${box.id}`,
- x1: px,
- y1: py,
- x2: cx,
- y2: cy,
- path: `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`,
- });
- } else {
- // Vertical spine for Level >= 1 -> Level >= 2
- const spineX = pRect.left + 32 - rect.left + scrollLeft; // 32px indent from parent's left edge
- const py = pRect.bottom - rect.top + scrollTop;
- const cx = cRect.left - rect.left + scrollLeft; // Child's left edge
- const cy = cRect.top + 24 - rect.top + scrollTop; // Middle of child's header
-
- newLines.push({
- key: `${parentId}->${box.id}`,
- x1: spineX,
- y1: py,
- x2: cx,
- y2: cy,
- path: `M ${spineX} ${py} L ${spineX} ${cy} L ${cx} ${cy}`,
- });
- }
- }
-
- setLines(newLines);
- setSvgSize({
- width: Math.max(container.scrollWidth, rect.width),
- height: Math.max(container.scrollHeight, rect.height),
- });
- }, []);
-
- React.useLayoutEffect(() => {
- const _forceTrigger = rootNodes.length + usersMap.size;
- const timeout = setTimeout(drawLines, 150);
- window.addEventListener("resize", drawLines);
- return () => {
- clearTimeout(timeout);
- window.removeEventListener("resize", drawLines);
- };
- }, [drawLines, rootNodes.length, usersMap.size]);
-
- if (tenantsQuery.isLoading || usersQuery.isLoading) {
- return (
- 로딩 중...
- );
- }
-
- // Count unique users across the fetched payload
- const totalUniqueUsers =
- usersQuery.data?.items?.filter((u) => u.status === "active").length || 0;
-
- const targetNodes =
- selectedDept === "전체"
- ? rootNodes
- : rootNodes.filter((n) => n.name === selectedDept);
-
- return (
-
-
-
-
-
-
-
- {targetNodes.map((tNode) => {
- const orgNode = buildHierarchy(tNode, 0);
- return (
-
-
-
- );
- })}
-
-
-
- );
-}
-
-// --------------------- Node Rendering --------------------- //
-
-const ROLE_ORDER = [
- "사장",
- "부사장",
- "전무",
- "상무",
- "이사",
- "수석",
- "책임",
- "선임",
- "주임",
- "사원",
-];
-
-function getRankWeight(u: UserSummary) {
- const role = u.position || "";
- let idx = ROLE_ORDER.indexOf(role);
- if (idx === -1) idx = 99;
- const isLeader = u.position?.endsWith("장") || u.jobTitle?.endsWith("장");
- return (isLeader ? -100 : 0) + idx;
-}
-
-function OrgNodeView({
- node,
- parentId,
- onToggle,
-}: {
- node: OrgNode;
- parentId: string | null;
- onToggle: () => void;
-}) {
- const [collapsed, setCollapsed] = React.useState(false);
- const myId = `node-${node.level}-${node.id}`;
-
- const toggle = () => {
- setCollapsed(!collapsed);
- setTimeout(onToggle, 100);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" || e.key === " ") {
- toggle();
- }
- };
-
- const membersToShow = [...node.members].sort(
- (a, b) => getRankWeight(a) - getRankWeight(b),
- );
-
- const isVerticalChildren = node.level >= 1;
- const isVerticallyStacked = node.level >= 1;
-
- const embedChildren =
- node.children.length > 0 &&
- node.children.every((c) => c.children.length === 0);
-
- return (
-
-
-
-
- {!collapsed && membersToShow.length > 0 && (
-
- {membersToShow.map((m) => (
-
- ))}
-
- )}
-
- {!collapsed && embedChildren && (
-
- {node.children.map((child) => {
- const childMembers = [...child.members].sort(
- (a, b) => getRankWeight(a) - getRankWeight(b),
- );
- return (
-
-
- {child.name}
-
- ({child.totalCount || child.members.length})
-
-
- {childMembers.length > 0 && (
-
- {childMembers.map((m) => (
-
- ))}
-
- )}
-
- );
- })}
-
- )}
-
-
- {!collapsed && !embedChildren && node.children.length > 0 && (
-
- {node.children.map((c) => (
-
- ))}
-
- )}
-
- );
-}
-
-function MemberCard({
- member,
- companyCode,
-}: { member: UserSummary; companyCode?: string }) {
- const coColor = (() => {
- const c = (companyCode || member.companyCode || "").toLowerCase();
- if (c.includes("hanmac")) return "bg-[#1E3A8A] text-white border-[#1E3A8A]";
- if (c.includes("saman")) return "bg-[#047857] text-white border-[#047857]";
- if (c.includes("ptc")) return "bg-[#C2410C] text-white border-[#C2410C]";
- if (c.includes("baron")) return "bg-[#4338CA] text-white border-[#4338CA]";
- return "bg-slate-600 text-white border-slate-700";
- })();
-
- const roleBadge =
- member.jobTitle && member.jobTitle !== member.position
- ? member.jobTitle
- : member.position?.endsWith("장")
- ? member.position
- : null;
-
- return (
-
-
-
-
- {member.name}
-
- {member.position && member.position !== roleBadge && (
-
- {member.position}
-
- )}
-
- {roleBadge && (
-
- {roleBadge}
-
- )}
-
-
- );
-}
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 3be0125b..57d5645a 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -268,10 +268,12 @@ func main() {
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
+ sharedLinkRepo := repository.NewSharedLinkRepository(db)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
+ sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입
@@ -291,7 +293,7 @@ func main() {
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, tenantService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo
- tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
+ tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
@@ -522,6 +524,9 @@ func main() {
api.Get("/audit", auditHandler.ListLogs)
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
+ // [New] Shared Link Public API (No Auth required)
+ api.Get("/public/orgchart", tenantHandler.GetPublicOrgChart)
+
// Public Tenant Registration
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
@@ -615,6 +620,12 @@ func main() {
// Tenant Management (Mixed roles, handler filters results)
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
+
+ // [New] Shared Link Management
+ admin.Post("/tenants/:id/share-links", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.CreateShareLink)
+ admin.Get("/tenants/:id/share-links", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.ListShareLinks)
+ admin.Delete("/share-links/:id", requireAdmin, tenantHandler.DeleteShareLink)
+
admin.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk)
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go
index e4cd6fb8..fad84419 100644
--- a/backend/internal/bootstrap/bootstrap.go
+++ b/backend/internal/bootstrap/bootstrap.go
@@ -41,6 +41,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.ClientSecret{},
&domain.ClientConsent{},
&domain.KetoOutbox{},
+ &domain.SharedLink{},
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
- )
-}
+ )
+ }
diff --git a/backend/internal/domain/shared_link.go b/backend/internal/domain/shared_link.go
new file mode 100644
index 00000000..eca64173
--- /dev/null
+++ b/backend/internal/domain/shared_link.go
@@ -0,0 +1,53 @@
+package domain
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type SharedLink struct {
+ ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
+ TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"`
+ Token string `gorm:"uniqueIndex;not null" json:"token"`
+ Name string `gorm:"not null" json:"name"` // 링크 식별을 위한 이름 (예: "24년 상반기 채용공고용")
+ Description string `json:"description"`
+ AccessLevel string `gorm:"default:'READ_ONLY'" json:"accessLevel"`
+ IsActive bool `gorm:"default:true" json:"isActive"`
+ ExpiresAt *time.Time `json:"expiresAt"`
+ Password string `json:"-"` // 필요 시 비밀번호 (선택 사항)
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // Relation
+ Tenant Tenant `gorm:"foreignKey:TenantID" json:"-"`
+}
+
+func (s *SharedLink) BeforeCreate(tx *gorm.DB) (err error) {
+ if s.ID == "" {
+ s.ID = uuid.NewString()
+ }
+ if s.Token == "" {
+ // 32바이트(64자)의 강력한 난수 토큰 생성
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return err
+ }
+ s.Token = hex.EncodeToString(b)
+ }
+ return
+}
+
+func (s *SharedLink) IsValid() bool {
+ if !s.IsActive {
+ return false
+ }
+ if s.ExpiresAt != nil && s.ExpiresAt.Before(time.Now()) {
+ return false
+ }
+ return true
+}
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index 5fd7a986..903bd720 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -3784,6 +3784,13 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
if str, ok := val.(string); ok && str != "" {
return str
}
+ // Handle numeric types by converting to string
+ if num, ok := val.(float64); ok {
+ return fmt.Sprint(num)
+ }
+ if num, ok := val.(int); ok {
+ return fmt.Sprint(num)
+ }
}
}
return ""
diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go
index 5ba21713..24d9223e 100644
--- a/backend/internal/handler/auth_handler_async_test.go
+++ b/backend/internal/handler/auth_handler_async_test.go
@@ -114,10 +114,20 @@ func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string)
return 0, nil
}
+func (m *AsyncMockUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
+ args := m.Called(ctx, tenantIDs)
+ return args.Get(0).([]domain.User), args.Error(1)
+}
+
func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
+func (m *AsyncMockUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
+ args := m.Called(ctx, codes)
+ return args.Get(0).([]domain.User), args.Error(1)
+}
+
func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
return nil, nil
}
diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go
index 03b4fb17..16e7641f 100644
--- a/backend/internal/handler/tenant_handler.go
+++ b/backend/internal/handler/tenant_handler.go
@@ -20,9 +20,10 @@ type TenantHandler struct {
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
+ SharedLink service.SharedLinkService
}
-func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
+func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
@@ -30,6 +31,7 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repositor
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
+ SharedLink: sharedLink,
}
}
@@ -865,3 +867,136 @@ func normalizeTenantType(value string) string {
return ""
}
}
+
+
+func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error {
+ tenantID := c.Params("id")
+ var req struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ExpiresAt *time.Time `json:"expiresAt"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
+ }
+
+ link, err := h.SharedLink.CreateLink(c.Context(), tenantID, req.Name, req.Description, req.ExpiresAt)
+ if err != nil {
+ return errorJSON(c, fiber.StatusInternalServerError, err.Error())
+ }
+
+ return c.JSON(link)
+}
+
+func (h *TenantHandler) ListShareLinks(c *fiber.Ctx) error {
+ tenantID := c.Params("id")
+ links, err := h.SharedLink.GetLinksByTenant(c.Context(), tenantID)
+ if err != nil {
+ return errorJSON(c, fiber.StatusInternalServerError, err.Error())
+ }
+ return c.JSON(links)
+}
+
+func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
+ id := c.Params("id")
+ if err := h.SharedLink.DeactivateLink(c.Context(), id); err != nil {
+ return errorJSON(c, fiber.StatusInternalServerError, err.Error())
+ }
+ return c.JSON(fiber.Map{"message": "Share link deleted successfully"})
+}
+
+func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
+ token := c.Query("token")
+ if token == "" {
+ return errorJSON(c, fiber.StatusUnauthorized, "share token is required")
+ }
+
+ link, err := h.SharedLink.ValidateToken(c.Context(), token)
+ if err != nil {
+ return errorJSON(c, fiber.StatusUnauthorized, err.Error())
+ }
+
+ allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
+ if err != nil {
+ return errorJSON(c, fiber.StatusInternalServerError, err.Error())
+ }
+
+ parentMap := make(map[string]string)
+ for _, t := range allTenants {
+ if t.ParentID != nil {
+ parentMap[t.ID] = *t.ParentID
+ }
+ }
+
+ findRoot := func(id string) string {
+ curr := id
+ for {
+ p, exists := parentMap[curr]
+ if !exists || p == "" { break }
+ curr = p
+ }
+ return curr
+ }
+
+ sharedRootID := findRoot(link.TenantID)
+ var filteredTenants []domain.Tenant
+ var tenantIDs []string
+ var slugs []string
+
+ for _, t := range allTenants {
+ if findRoot(t.ID) == sharedRootID {
+ filteredTenants = append(filteredTenants, t)
+ tenantIDs = append(tenantIDs, t.ID)
+ slugs = append(slugs, t.Slug)
+ }
+ }
+
+ type publicUserSummary struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Position string `json:"position"`
+ JobTitle string `json:"jobTitle"`
+ CompanyCode string `json:"companyCode"`
+ Status string `json:"status"`
+ }
+
+ var publicUsers []publicUserSummary
+ seen := make(map[string]bool)
+
+ // Fetch users by IDs
+ var usersByID []domain.User
+ h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID)
+ for _, u := range usersByID {
+ if u.Status != "active" || seen[u.ID] { continue }
+ seen[u.ID] = true
+ cc := u.CompanyCode
+ if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug }
+ publicUsers = append(publicUsers, publicUserSummary{
+ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
+ })
+ }
+
+ // Fetch users by Slugs
+ var usersBySlug []domain.User
+ h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug)
+ for _, u := range usersBySlug {
+ if u.Status != "active" || seen[u.ID] { continue }
+ seen[u.ID] = true
+ cc := u.CompanyCode
+ if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug }
+ publicUsers = append(publicUsers, publicUserSummary{
+ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
+ })
+ }
+
+ tenantSummaries := make([]tenantSummary, 0, len(filteredTenants))
+ for _, t := range filteredTenants {
+ tenantSummaries = append(tenantSummaries, mapTenantSummary(t))
+ }
+
+ return c.JSON(fiber.Map{
+ "tenants": tenantSummaries,
+ "users": publicUsers,
+ "sharedWith": link.Name,
+ })
+}
diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go
index e5c346bf..1372cda0 100644
--- a/backend/internal/handler/tenant_handler_test.go
+++ b/backend/internal/handler/tenant_handler_test.go
@@ -127,10 +127,20 @@ func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID str
return 0, nil
}
+func (m *MockUserRepoForHandler) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
+ args := m.Called(ctx, tenantIDs)
+ return args.Get(0).([]domain.User), args.Error(1)
+}
+
func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
+func (m *MockUserRepoForHandler) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
+ args := m.Called(ctx, codes)
+ return args.Get(0).([]domain.User), args.Error(1)
+}
+
func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
args := m.Called(ctx, codes)
if args.Get(0) == nil {
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index ea522069..f2c2a2da 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -1266,6 +1266,25 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if traits == nil {
traits = map[string]interface{}{}
}
+
+ // [Preserve & Merge] Multi-Tenant Info
+ var existingCodes []string
+ if codes, ok := traits["companyCodes"].([]interface{}); ok {
+ for _, v := range codes {
+ if str, ok := v.(string); ok && str != "" {
+ existingCodes = append(existingCodes, str)
+ }
+ }
+ }
+ // Keto에서 "실제" 소속 정보를 먼저 확인 (엑셀 임포트 사용자 대응)
+ if len(existingCodes) <= 1 && h.TenantService != nil {
+ if joined, err := h.TenantService.ListJoinedTenants(c.Context(), userID); err == nil {
+ for _, t := range joined {
+ existingCodes = append(existingCodes, t.Slug)
+ }
+ }
+ }
+
if req.Name != nil {
traits["name"] = strings.TrimSpace(*req.Name)
}
@@ -1286,7 +1305,33 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["tenant_id"] = tenant.ID
}
}
+
+ // Add to existingCodes if not present
+ found := false
+ for _, existing := range existingCodes {
+ if existing == code {
+ found = true
+ break
+ }
+ }
+ if !found && code != "" {
+ existingCodes = append(existingCodes, code)
+ }
}
+
+ // Deduplicate and save back companyCodes
+ var uniqueCodes []string
+ seenCodes := map[string]bool{}
+ for _, c := range existingCodes {
+ if !seenCodes[c] && c != "" {
+ seenCodes[c] = true
+ uniqueCodes = append(uniqueCodes, c)
+ }
+ }
+ if len(uniqueCodes) > 0 {
+ traits["companyCodes"] = uniqueCodes
+ }
+
if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department)
}
@@ -1420,16 +1465,32 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
- // [Self-Healing] If the UI explicitly assigned the tenant, force a Keto relation sync.
- // This fixes issues where local DB had the tenant, but Keto failed to create the relation previously.
- if req.CompanyCode != nil && h.KetoOutboxRepo != nil && updatedLocalUser.TenantID != nil {
- _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
- Namespace: "Tenant",
- Object: *updatedLocalUser.TenantID,
- Relation: "members",
- Subject: "User:" + updatedLocalUser.ID,
- Action: domain.KetoOutboxActionCreate,
- })
+ // [Self-Healing] Sync all companyCodes to Keto
+ if h.KetoOutboxRepo != nil && h.TenantService != nil {
+ if codes, ok := updated.Traits["companyCodes"].([]interface{}); ok {
+ for _, cVal := range codes {
+ if cStr, ok := cVal.(string); ok && cStr != "" {
+ if tenant, err := h.TenantService.GetTenantBySlug(bgCtx, cStr); err == nil && tenant != nil {
+ _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
+ Namespace: "Tenant",
+ Object: tenant.ID,
+ Relation: "members",
+ Subject: "User:" + updatedLocalUser.ID,
+ Action: domain.KetoOutboxActionCreate,
+ })
+ }
+ }
+ }
+ } else if updatedLocalUser.TenantID != nil {
+ // Fallback if companyCodes doesn't exist
+ _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
+ Namespace: "Tenant",
+ Object: *updatedLocalUser.TenantID,
+ Relation: "members",
+ Subject: "User:" + updatedLocalUser.ID,
+ Action: domain.KetoOutboxActionCreate,
+ })
+ }
}
}()
}
diff --git a/backend/internal/repository/shared_link_repository.go b/backend/internal/repository/shared_link_repository.go
new file mode 100644
index 00000000..1747ceae
--- /dev/null
+++ b/backend/internal/repository/shared_link_repository.go
@@ -0,0 +1,51 @@
+package repository
+
+import (
+ "baron-sso-backend/internal/domain"
+ "context"
+
+ "gorm.io/gorm"
+)
+
+type SharedLinkRepository interface {
+ Create(ctx context.Context, link *domain.SharedLink) error
+ FindByToken(ctx context.Context, token string) (*domain.SharedLink, error)
+ FindByTenantID(ctx context.Context, tenantID string) ([]domain.SharedLink, error)
+ Delete(ctx context.Context, id string) error
+ Update(ctx context.Context, link *domain.SharedLink) error
+}
+
+type sharedLinkRepository struct {
+ db *gorm.DB
+}
+
+func NewSharedLinkRepository(db *gorm.DB) SharedLinkRepository {
+ return &sharedLinkRepository{db: db}
+}
+
+func (r *sharedLinkRepository) Create(ctx context.Context, link *domain.SharedLink) error {
+ return r.db.WithContext(ctx).Create(link).Error
+}
+
+func (r *sharedLinkRepository) FindByToken(ctx context.Context, token string) (*domain.SharedLink, error) {
+ var link domain.SharedLink
+ err := r.db.WithContext(ctx).Where("token = ? AND is_active = ?", token, true).First(&link).Error
+ if err != nil {
+ return nil, err
+ }
+ return &link, nil
+}
+
+func (r *sharedLinkRepository) FindByTenantID(ctx context.Context, tenantID string) ([]domain.SharedLink, error) {
+ var links []domain.SharedLink
+ err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&links).Error
+ return links, err
+}
+
+func (r *sharedLinkRepository) Delete(ctx context.Context, id string) error {
+ return r.db.WithContext(ctx).Delete(&domain.SharedLink{}, "id = ?", id).Error
+}
+
+func (r *sharedLinkRepository) Update(ctx context.Context, link *domain.SharedLink) error {
+ return r.db.WithContext(ctx).Save(link).Error
+}
diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go
index a28089a2..4ccaffad 100644
--- a/backend/internal/repository/user_repository.go
+++ b/backend/internal/repository/user_repository.go
@@ -20,6 +20,8 @@ type UserRepository interface {
CountByTenant(ctx context.Context, tenantID string) (int64, error)
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
+ FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error)
+ FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error)
Delete(ctx context.Context, id string) error
// Multiple identifiers support
@@ -261,3 +263,15 @@ func (r *userRepository) FindTenantIDByLoginID(ctx context.Context, loginID stri
}
return record.TenantID, nil
}
+
+func (r *userRepository) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
+ var users []domain.User
+ err := r.db.WithContext(ctx).Where("tenant_id IN ?", tenantIDs).Find(&users).Error
+ return users, err
+}
+
+func (r *userRepository) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
+ var users []domain.User
+ err := r.db.WithContext(ctx).Where("company_code IN ?", codes).Find(&users).Error
+ return users, err
+}
diff --git a/backend/internal/service/shared_link_service.go b/backend/internal/service/shared_link_service.go
new file mode 100644
index 00000000..ff213c78
--- /dev/null
+++ b/backend/internal/service/shared_link_service.go
@@ -0,0 +1,63 @@
+package service
+
+import (
+ "baron-sso-backend/internal/domain"
+ "baron-sso-backend/internal/repository"
+ "context"
+ "errors"
+ "time"
+)
+
+type SharedLinkService interface {
+ CreateLink(ctx context.Context, tenantID, name, description string, expiresAt *time.Time) (*domain.SharedLink, error)
+ ValidateToken(ctx context.Context, token string) (*domain.SharedLink, error)
+ GetLinksByTenant(ctx context.Context, tenantID string) ([]domain.SharedLink, error)
+ DeactivateLink(ctx context.Context, id string) error
+}
+
+type sharedLinkService struct {
+ repo repository.SharedLinkRepository
+}
+
+func NewSharedLinkService(repo repository.SharedLinkRepository) SharedLinkService {
+ return &sharedLinkService{repo: repo}
+}
+
+func (s *sharedLinkService) CreateLink(ctx context.Context, tenantID, name, description string, expiresAt *time.Time) (*domain.SharedLink, error) {
+ link := &domain.SharedLink{
+ TenantID: tenantID,
+ Name: name,
+ Description: description,
+ ExpiresAt: expiresAt,
+ IsActive: true,
+ AccessLevel: "READ_ONLY",
+ }
+
+ if err := s.repo.Create(ctx, link); err != nil {
+ return nil, err
+ }
+ return link, nil
+}
+
+func (s *sharedLinkService) ValidateToken(ctx context.Context, token string) (*domain.SharedLink, error) {
+ link, err := s.repo.FindByToken(ctx, token)
+ if err != nil {
+ return nil, errors.New("invalid or expired share link")
+ }
+
+ if !link.IsValid() {
+ return nil, errors.New("share link has expired or is inactive")
+ }
+
+ return link, nil
+}
+
+func (s *sharedLinkService) GetLinksByTenant(ctx context.Context, tenantID string) ([]domain.SharedLink, error) {
+ return s.repo.FindByTenantID(ctx, tenantID)
+}
+
+func (s *sharedLinkService) DeactivateLink(ctx context.Context, id string) error {
+ // 실제 삭제 대신 비활성화 처리 (soft-delete와 유사)
+ // 하지만 여기서는 간단히 활성 플래그만 끔
+ return s.repo.Delete(ctx, id) // 리포지토리의 Delete는 GORM의 DeletedAt을 사용하여 soft-delete함
+}
diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go
index 21901e8f..1f207ec1 100644
--- a/backend/internal/service/tenant_service_test.go
+++ b/backend/internal/service/tenant_service_test.go
@@ -143,6 +143,11 @@ func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID stri
return int64(args.Int(0)), args.Error(1)
}
+func (m *MockUserRepoForTenant) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
+ args := m.Called(ctx, tenantIDs)
+ return args.Get(0).([]domain.User), args.Error(1)
+}
+
func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
args := m.Called(tenantIDs)
if args.Get(0) == nil {
@@ -151,6 +156,11 @@ func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs
return args.Get(0).(map[string]int64), args.Error(1)
}
+func (m *MockUserRepoForTenant) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
+ args := m.Called(ctx, codes)
+ return args.Get(0).([]domain.User), args.Error(1)
+}
+
func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
args := m.Called(ctx, codes)
if args.Get(0) == nil {
diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go
index f774fe7d..209b58d9 100644
--- a/backend/internal/service/user_group_service_test.go
+++ b/backend/internal/service/user_group_service_test.go
@@ -86,6 +86,11 @@ func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string)
return int64(args.Int(0)), args.Error(1)
}
+func (m *MockUserRepository) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
+ args := m.Called(ctx, tenantIDs)
+ return args.Get(0).([]domain.User), args.Error(1)
+}
+
func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
args := m.Called(tenantIDs)
if args.Get(0) == nil {
@@ -94,6 +99,11 @@ func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []s
return args.Get(0).(map[string]int64), args.Error(1)
}
+func (m *MockUserRepository) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
+ args := m.Called(ctx, codes)
+ return args.Get(0).([]domain.User), args.Error(1)
+}
+
func (m *MockUserRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
args := m.Called(ctx, codes)
if args.Get(0) == nil {
diff --git a/compose.ory.yaml b/compose.ory.yaml
index e589acb2..5747560d 100644
--- a/compose.ory.yaml
+++ b/compose.ory.yaml
@@ -206,6 +206,7 @@ services:
- |
hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true
+ hydra delete oauth2-client --endpoint http://hydra:4445 orgfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint http://hydra:4445 ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true
hydra create oauth2-client \
@@ -228,6 +229,16 @@ services:
--token-endpoint-auth-method none \
--redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}
+ hydra create oauth2-client \
+ --endpoint http://hydra:4445 \
+ --id orgfront \
+ --name "OrgFront" \
+ --grant-type authorization_code,refresh_token \
+ --response-type code \
+ --scope openid,offline_access,profile,email \
+ --token-endpoint-auth-method none \
+ --redirect-uri ${ORGFRONT_CALLBACK_URLS:-http://localhost:5175/auth/callback}
+
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} \
diff --git a/docker-compose.yaml b/docker-compose.yaml
index f7792782..b4920b0b 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -86,6 +86,27 @@ services:
networks:
- baron_net
+ orgfront:
+ build:
+ context: ../baron-orgchart
+ dockerfile: Dockerfile
+ container_name: baron_orgfront
+ env_file:
+ - .env
+ environment:
+ - APP_ENV=${APP_ENV:-development}
+ - API_PROXY_TARGET=http://baron_backend:3000
+ - USERFRONT_URL=${USERFRONT_URL}
+ ports:
+ - "${ORGFRONT_PORT:-5175}:5175"
+ volumes:
+ - ../baron-orgchart:/app
+ - ./locales:/locales
+ - /app/node_modules
+ networks:
+ - baron_net
+
+
userfront:
build:
context: .