1
0
forked from baron/baron-sso

feat(orgchart): Introduce standalone orgchart RP and shared link public API

This commit includes:
- Added SharedLink data model and Keto-bypassed public API for orgchart view
- Configured 'orgfront' as a new OAuth2 client in hydra
- Applied MH Dashboard premium beige theme to OrgChart
- Implemented user lookup fallback to company code
This commit is contained in:
2026-04-14 18:01:27 +09:00
parent a1d508ed69
commit 948dc2236b
10 changed files with 415 additions and 146 deletions

View File

@@ -53,31 +53,21 @@ export function TenantOrgChartPage() {
const uMap = new Map<string, UserSummary[]>();
// 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<string>();
const primarySlug =
u.tenantSlug?.toLowerCase() || u.companyCode?.toLowerCase() || "";
if (primarySlug) {
slugs.add(primarySlug);
}
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());
}
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);
}
@@ -106,10 +96,8 @@ export function TenantOrgChartPage() {
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;
@@ -154,13 +142,9 @@ export function TenantOrgChartPage() {
if (pRect.width === 0 || cRect.width === 0) continue;
const parentLevel = Number.parseInt(
parent.getAttribute("data-level") || "0",
10,
);
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;
@@ -176,11 +160,10 @@ export function TenantOrgChartPage() {
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 spineX = pRect.left + 24 - rect.left + scrollLeft;
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
const cx = cRect.left - rect.left + scrollLeft;
const cy = cRect.top + 20 - rect.top + scrollTop;
newLines.push({
key: `${parentId}->${box.id}`,
@@ -201,7 +184,6 @@ export function TenantOrgChartPage() {
}, []);
React.useLayoutEffect(() => {
const _forceTrigger = rootNodes.length + usersMap.size;
const timeout = setTimeout(drawLines, 150);
window.addEventListener("resize", drawLines);
return () => {
@@ -216,52 +198,45 @@ export function TenantOrgChartPage() {
);
}
// 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);
const totalUniqueUsers = usersQuery.data?.items?.filter((u) => u.status === "active").length || 0;
const targetNodes = selectedDept === "전체" ? rootNodes : rootNodes.filter((n) => n.name === selectedDept);
return (
<div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-slate-50 rounded-xl overflow-hidden shadow-sm border border-slate-200">
<header className="flex items-center justify-between px-6 py-4 bg-white border-b border-slate-200 shadow-sm z-10 shrink-0">
<div className="flex items-center gap-4">
<div>
<h2 className="text-xl font-bold text-slate-800"></h2>
<p className="text-xs text-slate-500">
() .
</p>
</div>
<div className="flex flex-col h-[calc(100vh-theme(spacing.32))] bg-[#f6efe6] rounded-xl overflow-hidden shadow-sm border border-[#e0d5c1]">
<header className="flex flex-col sm:flex-row items-start sm:items-center justify-between px-6 py-4 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] border-b border-[#f2c484]/30 z-10 shrink-0">
<div className="flex flex-col gap-1 mb-4 sm:mb-0">
<p className="text-[#f2c484] text-xs font-bold uppercase tracking-wider">MH Dashboard</p>
<h2 className="text-xl font-black text-[#f7f0e4]"> </h2>
</div>
<div className="flex gap-2 overflow-x-auto max-w-2xl custom-scrollbar pb-1">
<div className="flex items-center gap-2 overflow-x-auto max-w-full custom-scrollbar">
{["전체", ...depts].map((d) => (
<button
key={d}
type="button"
onClick={() => {
setSelectedDept(d);
setLines([]); // Reset lines during switch
setLines([]);
}}
className={`whitespace-nowrap px-4 py-1.5 text-sm font-semibold rounded-full border transition-colors ${
className={`whitespace-nowrap px-4 py-2 text-xs font-bold rounded-full transition-all border ${
selectedDept === d
? "bg-slate-800 text-white border-slate-800"
: "bg-white text-slate-600 border-slate-200 hover:bg-slate-100"
? "bg-[linear-gradient(180deg,rgba(255,253,248,0.98),rgba(245,235,221,0.94))] text-[#0a2a22] border-[#f2c484]/40 shadow-sm"
: "bg-white/10 text-[#f7f0e4]/70 border-[#f2c484]/30 hover:text-[#f7f0e4] hover:border-[#f2c484]/50"
}`}
>
{d}
</button>
))}
<div className="ml-2 whitespace-nowrap px-4 py-1.5 bg-blue-50 text-blue-700 font-bold rounded-full border border-blue-200 text-sm flex items-center">
<div className="ml-2 whitespace-nowrap px-4 py-2 bg-[#f2c484]/10 text-[#f2c484] font-black rounded-full border border-[#f2c484]/30 text-xs shadow-sm">
{totalUniqueUsers}
</div>
</div>
</header>
<div
className="flex-1 overflow-auto relative p-8 bg-[#f8fafc]"
className="flex-1 overflow-auto relative p-8 md:p-12"
style={{
background: "radial-gradient(circle at top left, rgba(214, 138, 58, 0.08), transparent 24%), radial-gradient(circle at top right, rgba(47, 153, 115, 0.05), transparent 20%), linear-gradient(180deg, rgba(246, 239, 230, 0.98), rgba(241, 234, 223, 0.96))"
}}
ref={containerRef}
>
<svg
@@ -276,27 +251,20 @@ export function TenantOrgChartPage() {
<path
key={l.key}
d={l.path}
stroke="#cbd5e1"
strokeWidth="2"
stroke="#bca58a"
strokeWidth="1.5"
fill="none"
strokeLinejoin="round"
/>
))}
</svg>
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-32">
{targetNodes.map((tNode) => {
const orgNode = buildHierarchy(tNode, 0);
return (
<div
key={orgNode.id}
className="flex flex-col items-center w-full"
>
<OrgNodeView
node={orgNode}
parentId={null}
onToggle={drawLines}
/>
<div key={orgNode.id} className="flex flex-col items-center w-full">
<OrgNodeView node={orgNode} parentId={null} onToggle={drawLines} />
</div>
);
})}
@@ -346,89 +314,63 @@ function OrgNodeView({
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 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);
node.children.length > 0 && node.children.every((c) => c.children.length === 0);
// Determine header color based on level
const headerBgClass = node.level === 0 ? "bg-[#0a2a22]" : "bg-[#2f5547]";
return (
<div
className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}
>
<div className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-4`}>
<div
id={myId}
data-parent={parentId || undefined}
data-level={node.level}
className={`bg-white border rounded-xl shadow-sm mb-4 flex flex-col transition-all shrink-0 ${
node.level === 0 ? "border-slate-800 border-t-4" : "border-slate-300"
} ${collapsed ? "opacity-80" : ""}`}
style={{ width: "fit-content", minWidth: "260px", maxWidth: "400px" }}
className={`bg-white border border-[#e0d5c1] rounded-[10px] shadow-sm flex flex-col transition-all shrink-0 ${collapsed ? "opacity-80 scale-[0.98]" : ""}`}
style={{ width: "fit-content", minWidth: "320px", maxWidth: "400px" }}
>
<button
type="button"
className={`px-4 py-2 font-bold flex justify-between items-center cursor-pointer select-none hover:bg-slate-50 transition-colors rounded-t-xl outline-none focus-visible:ring-2 focus-visible:ring-primary w-full text-left ${
node.level === 0
? "text-slate-800 text-lg"
: "text-slate-700 text-sm"
}`}
className={`${headerBgClass} text-white px-4 py-3 font-black flex justify-center items-center gap-2 cursor-pointer select-none outline-none w-full text-center ${
node.level === 0 ? "text-[17px]" : "text-[15px]"
} ${membersToShow.length > 0 || embedChildren ? "rounded-t-[9px]" : "rounded-[9px]"}`}
onClick={toggle}
onKeyDown={handleKeyDown}
>
<span>{node.name}</span>
<span className="text-slate-400 font-normal text-xs ml-4">
({node.totalCount || node.members.length})
<span className="text-white/60 font-semibold text-xs bg-black/20 px-2 py-0.5 rounded-full">
{node.totalCount || node.members.length}
</span>
</button>
{!collapsed && membersToShow.length > 0 && (
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
<div className="p-3 grid grid-cols-2 gap-2 w-full">
{membersToShow.map((m) => (
<MemberCard
key={m.id}
member={m}
companyCode={node.companyCode}
/>
<MemberCard key={m.id} member={m} companyCode={node.companyCode} />
))}
</div>
)}
{!collapsed && embedChildren && (
<div className="flex flex-col gap-2 p-2 pt-0 w-full">
<div className="flex flex-col gap-3 p-3 pt-0 w-full">
{node.children.map((child) => {
const childMembers = [...child.members].sort(
(a, b) => getRankWeight(a) - getRankWeight(b),
);
const childMembers = [...child.members].sort((a, b) => getRankWeight(a) - getRankWeight(b));
return (
<div
key={child.id}
className="bg-slate-50 border border-slate-200 rounded-lg p-1.5 flex flex-col gap-1.5 w-full"
className="bg-[#f8f9fa] border border-[#e5e7eb] rounded-[8px] flex flex-col w-full overflow-hidden"
>
<div className="text-[11px] font-bold text-slate-600 flex justify-between px-1">
<div className="bg-[#7b93ab] text-white text-[13px] font-bold px-3 py-1.5 flex justify-between items-center">
<span>{child.name}</span>
<span className="text-slate-400 font-normal">
({child.totalCount || child.members.length})
</span>
<span className="bg-black/10 px-1.5 py-0.5 rounded text-[11px] font-medium">{child.totalCount || child.members.length}</span>
</div>
{childMembers.length > 0 && (
<div className="grid grid-cols-2 gap-1 w-full">
<div className="grid grid-cols-2 gap-2 p-2 w-full">
{childMembers.map((m) => (
<MemberCard
key={m.id}
member={m}
companyCode={child.companyCode}
/>
<MemberCard key={m.id} member={m} companyCode={child.companyCode} />
))}
</div>
)}
@@ -440,20 +382,9 @@ function OrgNodeView({
</div>
{!collapsed && !embedChildren && node.children.length > 0 && (
<div
className={`flex ${
isVerticalChildren
? "flex-col items-start pl-12 gap-4 w-full"
: "flex-row gap-10 justify-center items-start"
} relative`}
>
<div className={`flex ${isVerticalChildren ? "flex-col items-start pl-8 gap-8 w-full" : "flex-row gap-12 justify-center items-start"} relative mt-4`}>
{node.children.map((c) => (
<OrgNodeView
key={c.id}
node={c}
parentId={myId}
onToggle={onToggle}
/>
<OrgNodeView key={c.id} node={c} parentId={myId} onToggle={onToggle} />
))}
</div>
)}
@@ -467,41 +398,39 @@ function MemberCard({
}: { 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";
if (c.includes("hanmac")) return "border-l-[#ef4444]";
if (c.includes("saman")) return "border-l-[#ffb366]";
if (c.includes("ptc")) return "border-l-[#a855f7]";
if (c.includes("baron")) return "border-l-[#3b82f6]";
return "border-l-slate-400";
})();
const roleBadge =
member.jobTitle && member.jobTitle !== member.position
const roleBadge = member.jobTitle && member.jobTitle !== member.position
? member.jobTitle
: member.position?.endsWith("장")
? member.position
: null;
return (
<div
className={`relative flex items-center px-1.5 h-[30px] rounded border shadow-sm overflow-hidden w-full leading-none ${coColor}`}
>
<div className="flex items-center gap-1 min-w-0 w-full">
<div className={`flex flex-col px-2.5 py-2 rounded-[4px] border border-[#e5e7eb] bg-white border-l-[4px] w-full transition-transform hover:-translate-y-[2px] hover:shadow-md cursor-pointer ${coColor}`}>
<div className="flex items-center justify-between min-w-0 w-full mb-1">
<div className="flex items-baseline gap-1 truncate shrink-0">
<span className="font-bold text-[11px] whitespace-nowrap">
<span className="font-black text-[12px] text-[#334155] whitespace-nowrap">
{member.name}
</span>
{member.position && member.position !== roleBadge && (
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">
{member.position}
{roleBadge && (
<span className="text-[#2f5547] text-[9px] font-extrabold ml-1 truncate">
{roleBadge}
</span>
)}
</div>
{roleBadge && (
<span className="bg-white/20 text-[9px] px-1 py-[1.5px] rounded-[3px] font-bold tracking-tight shrink-0 whitespace-nowrap ml-auto">
{roleBadge}
</span>
)}
</div>
<div className="flex items-center min-w-0 w-full">
<span className="text-[#94a3b8] text-[9px] font-medium truncate">
{member.position || "사원"}
</span>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,6 +86,7 @@ services:
networks:
- baron_net
userfront:
build:
context: .