forked from baron/baron-sso
Merge pull request 'feature/org-chart-tab-separation' (#568) from feature/org-chart-tab-separation into dev
Reviewed-on: baron/baron-sso#568
This commit is contained in:
@@ -11,7 +11,6 @@ import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdmin
|
|||||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
import { TenantOrgChartPage } from "../features/tenants/routes/TenantOrgChartPage";
|
|
||||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||||
@@ -41,7 +40,6 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "users/new", element: <UserCreatePage /> },
|
{ path: "users/new", element: <UserCreatePage /> },
|
||||||
{ path: "users/:id", element: <UserDetailPage /> },
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/org-chart", element: <TenantOrgChartPage /> },
|
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
{
|
{
|
||||||
path: "tenants/:tenantId",
|
path: "tenants/:tenantId",
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ import {
|
|||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
const staticNavItems = [
|
interface NavItem {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
icon: React.ComponentType<{ size?: number | string }>;
|
||||||
|
isExternal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticNavItems: NavItem[] = [
|
||||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
||||||
@@ -108,8 +115,9 @@ function AppLayout() {
|
|||||||
});
|
});
|
||||||
filteredItems.splice(2, 0, {
|
filteredItems.splice(2, 0, {
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: "/tenants/org-chart",
|
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||||
icon: Network,
|
icon: Network,
|
||||||
|
isExternal: true,
|
||||||
});
|
});
|
||||||
} else if (isTenantAdmin || manageableCount > 0) {
|
} else if (isTenantAdmin || manageableCount > 0) {
|
||||||
if (manageableCount <= 1 && profile?.tenantId) {
|
if (manageableCount <= 1 && profile?.tenantId) {
|
||||||
@@ -130,16 +138,18 @@ function AppLayout() {
|
|||||||
0,
|
0,
|
||||||
{
|
{
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: "/tenants/org-chart",
|
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||||
icon: Network,
|
icon: Network,
|
||||||
|
isExternal: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
|
||||||
filteredItems.splice(1, 0, {
|
filteredItems.splice(1, 0, {
|
||||||
label: "ui.admin.nav.org_chart",
|
label: "ui.admin.nav.org_chart",
|
||||||
to: "/tenants/org-chart",
|
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
|
||||||
icon: Network,
|
icon: Network,
|
||||||
|
isExternal: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +450,8 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{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 isOrgChart = location.pathname === "/tenants/org-chart";
|
||||||
const isTenantsRoot = to === "/tenants";
|
const isTenantsRoot = to === "/tenants";
|
||||||
const isCustomActive = isTenantsRoot
|
const isCustomActive = isTenantsRoot
|
||||||
@@ -449,6 +460,21 @@ function AppLayout() {
|
|||||||
? location.pathname === "/"
|
? location.pathname === "/"
|
||||||
: location.pathname.startsWith(to);
|
: location.pathname.startsWith(to);
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={to}
|
||||||
|
href={to}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span>{t(label, label)}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
|
|||||||
@@ -1,507 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
|
||||||
import * as React from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
|
||||||
import {
|
|
||||||
type UserSummary,
|
|
||||||
fetchTenants,
|
|
||||||
fetchUsers,
|
|
||||||
} from "../../../lib/adminApi";
|
|
||||||
import { t } from "../../../lib/i18n";
|
|
||||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
|
||||||
|
|
||||||
interface OrgNode {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
level: number;
|
|
||||||
members: UserSummary[];
|
|
||||||
children: OrgNode[];
|
|
||||||
totalCount?: number;
|
|
||||||
companyCode?: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TenantOrgChartPage() {
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>(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<string, UserSummary[]>() };
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string>("전체");
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="p-8 text-center text-muted-foreground">로딩 중...</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<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>
|
|
||||||
<div className="flex gap-2 overflow-x-auto max-w-2xl custom-scrollbar pb-1">
|
|
||||||
{["전체", ...depts].map((d) => (
|
|
||||||
<button
|
|
||||||
key={d}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDept(d);
|
|
||||||
setLines([]); // Reset lines during switch
|
|
||||||
}}
|
|
||||||
className={`whitespace-nowrap px-4 py-1.5 text-sm font-semibold rounded-full border transition-colors ${
|
|
||||||
selectedDept === d
|
|
||||||
? "bg-slate-800 text-white border-slate-800"
|
|
||||||
: "bg-white text-slate-600 border-slate-200 hover:bg-slate-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{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">
|
|
||||||
총 {totalUniqueUsers}명
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex-1 overflow-auto relative p-8 bg-[#f8fafc]"
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute top-0 left-0 pointer-events-none z-0"
|
|
||||||
style={{
|
|
||||||
width: svgSize.width ? `${svgSize.width}px` : "100%",
|
|
||||||
height: svgSize.height ? `${svgSize.height}px` : "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lines.map((l) => (
|
|
||||||
<path
|
|
||||||
key={l.key}
|
|
||||||
d={l.path}
|
|
||||||
stroke="#cbd5e1"
|
|
||||||
strokeWidth="2"
|
|
||||||
fill="none"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-16 relative z-10 w-max mx-auto items-center pb-24">
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------- 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 (
|
|
||||||
<div
|
|
||||||
className={`flex flex-col ${isVerticallyStacked ? "items-start w-full" : "items-center"} mb-3`}
|
|
||||||
>
|
|
||||||
<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" }}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
}`}
|
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!collapsed && membersToShow.length > 0 && (
|
|
||||||
<div className="p-1.5 pt-0 grid grid-cols-2 gap-1 w-full">
|
|
||||||
{membersToShow.map((m) => (
|
|
||||||
<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">
|
|
||||||
{node.children.map((child) => {
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="text-[11px] font-bold text-slate-600 flex justify-between px-1">
|
|
||||||
<span>{child.name}</span>
|
|
||||||
<span className="text-slate-400 font-normal">
|
|
||||||
({child.totalCount || child.members.length})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{childMembers.length > 0 && (
|
|
||||||
<div className="grid grid-cols-2 gap-1 w-full">
|
|
||||||
{childMembers.map((m) => (
|
|
||||||
<MemberCard
|
|
||||||
key={m.id}
|
|
||||||
member={m}
|
|
||||||
companyCode={child.companyCode}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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`}
|
|
||||||
>
|
|
||||||
{node.children.map((c) => (
|
|
||||||
<OrgNodeView
|
|
||||||
key={c.id}
|
|
||||||
node={c}
|
|
||||||
parentId={myId}
|
|
||||||
onToggle={onToggle}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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 items-baseline gap-1 truncate shrink-0">
|
|
||||||
<span className="font-bold text-[11px] whitespace-nowrap">
|
|
||||||
{member.name}
|
|
||||||
</span>
|
|
||||||
{member.position && member.position !== roleBadge && (
|
|
||||||
<span className="text-[10px] opacity-90 whitespace-nowrap font-medium">
|
|
||||||
{member.position}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -268,10 +268,12 @@ func main() {
|
|||||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||||
|
sharedLinkRepo := repository.NewSharedLinkRepository(db)
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
oryAdminProvider := service.NewOryProvider()
|
oryAdminProvider := service.NewOryProvider()
|
||||||
|
|
||||||
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
|
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
|
||||||
|
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
|
||||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
|
|
||||||
@@ -291,7 +293,7 @@ func main() {
|
|||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, tenantService, authHandler)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, tenantService, authHandler)
|
||||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||||
devHandler.AuditRepo = auditRepo
|
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)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
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", auditHandler.ListLogs)
|
||||||
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
||||||
|
|
||||||
|
// [New] Shared Link Public API (No Auth required)
|
||||||
|
api.Get("/public/orgchart", tenantHandler.GetPublicOrgChart)
|
||||||
|
|
||||||
// Public Tenant Registration
|
// Public Tenant Registration
|
||||||
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
|
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
|
||||||
|
|
||||||
@@ -615,6 +620,12 @@ func main() {
|
|||||||
// Tenant Management (Mixed roles, handler filters results)
|
// Tenant Management (Mixed roles, handler filters results)
|
||||||
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
||||||
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
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.Delete("/tenants/bulk", requireSuperAdmin, tenantHandler.DeleteTenantsBulk)
|
||||||
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
|
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)
|
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
&domain.ClientSecret{},
|
&domain.ClientSecret{},
|
||||||
&domain.ClientConsent{},
|
&domain.ClientConsent{},
|
||||||
&domain.KetoOutbox{},
|
&domain.KetoOutbox{},
|
||||||
|
&domain.SharedLink{},
|
||||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
53
backend/internal/domain/shared_link.go
Normal file
53
backend/internal/domain/shared_link.go
Normal 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
|
||||||
|
}
|
||||||
@@ -3784,6 +3784,13 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
|
|||||||
if str, ok := val.(string); ok && str != "" {
|
if str, ok := val.(string); ok && str != "" {
|
||||||
return 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 ""
|
return ""
|
||||||
|
|||||||
@@ -114,10 +114,20 @@ func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string)
|
|||||||
return 0, nil
|
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) {
|
func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
return nil, nil
|
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) {
|
func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ type TenantHandler struct {
|
|||||||
Keto service.KetoService
|
Keto service.KetoService
|
||||||
KetoOutbox repository.KetoOutboxRepository
|
KetoOutbox repository.KetoOutboxRepository
|
||||||
KratosAdmin service.KratosAdminService
|
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{
|
return &TenantHandler{
|
||||||
DB: db,
|
DB: db,
|
||||||
Service: svc,
|
Service: svc,
|
||||||
@@ -30,6 +31,7 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repositor
|
|||||||
Keto: keto,
|
Keto: keto,
|
||||||
KetoOutbox: outbox,
|
KetoOutbox: outbox,
|
||||||
KratosAdmin: kratos,
|
KratosAdmin: kratos,
|
||||||
|
SharedLink: sharedLink,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,3 +867,136 @@ func normalizeTenantType(value string) string {
|
|||||||
return ""
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,10 +127,20 @@ func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID str
|
|||||||
return 0, nil
|
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) {
|
func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
return nil, nil
|
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) {
|
func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||||
args := m.Called(ctx, codes)
|
args := m.Called(ctx, codes)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
|
|||||||
@@ -1266,6 +1266,25 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if traits == nil {
|
if traits == nil {
|
||||||
traits = map[string]interface{}{}
|
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 {
|
if req.Name != nil {
|
||||||
traits["name"] = strings.TrimSpace(*req.Name)
|
traits["name"] = strings.TrimSpace(*req.Name)
|
||||||
}
|
}
|
||||||
@@ -1286,7 +1305,33 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
traits["tenant_id"] = tenant.ID
|
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 {
|
if req.Department != nil {
|
||||||
traits["department"] = strings.TrimSpace(*req.Department)
|
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.
|
// [Self-Healing] Sync all companyCodes to Keto
|
||||||
// This fixes issues where local DB had the tenant, but Keto failed to create the relation previously.
|
if h.KetoOutboxRepo != nil && h.TenantService != nil {
|
||||||
if req.CompanyCode != nil && h.KetoOutboxRepo != nil && updatedLocalUser.TenantID != nil {
|
if codes, ok := updated.Traits["companyCodes"].([]interface{}); ok {
|
||||||
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
for _, cVal := range codes {
|
||||||
Namespace: "Tenant",
|
if cStr, ok := cVal.(string); ok && cStr != "" {
|
||||||
Object: *updatedLocalUser.TenantID,
|
if tenant, err := h.TenantService.GetTenantBySlug(bgCtx, cStr); err == nil && tenant != nil {
|
||||||
Relation: "members",
|
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
|
||||||
Subject: "User:" + updatedLocalUser.ID,
|
Namespace: "Tenant",
|
||||||
Action: domain.KetoOutboxActionCreate,
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
51
backend/internal/repository/shared_link_repository.go
Normal file
51
backend/internal/repository/shared_link_repository.go
Normal 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
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ type UserRepository interface {
|
|||||||
CountByTenant(ctx context.Context, tenantID string) (int64, error)
|
CountByTenant(ctx context.Context, tenantID string) (int64, error)
|
||||||
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
||||||
CountByCompanyCodes(ctx context.Context, codes []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
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
// Multiple identifiers support
|
// Multiple identifiers support
|
||||||
@@ -261,3 +263,15 @@ func (r *userRepository) FindTenantIDByLoginID(ctx context.Context, loginID stri
|
|||||||
}
|
}
|
||||||
return record.TenantID, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
63
backend/internal/service/shared_link_service.go
Normal file
63
backend/internal/service/shared_link_service.go
Normal 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함
|
||||||
|
}
|
||||||
@@ -143,6 +143,11 @@ func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID stri
|
|||||||
return int64(args.Int(0)), args.Error(1)
|
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) {
|
func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
args := m.Called(tenantIDs)
|
args := m.Called(tenantIDs)
|
||||||
if args.Get(0) == nil {
|
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)
|
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) {
|
func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||||
args := m.Called(ctx, codes)
|
args := m.Called(ctx, codes)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string)
|
|||||||
return int64(args.Int(0)), args.Error(1)
|
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) {
|
func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
args := m.Called(tenantIDs)
|
args := m.Called(tenantIDs)
|
||||||
if args.Get(0) == nil {
|
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)
|
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) {
|
func (m *MockUserRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||||
args := m.Called(ctx, codes)
|
args := m.Called(ctx, codes)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
|
|||||||
@@ -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 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 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 delete oauth2-client --endpoint http://hydra:4445 ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true
|
||||||
|
|
||||||
hydra create oauth2-client \
|
hydra create oauth2-client \
|
||||||
@@ -228,6 +229,16 @@ services:
|
|||||||
--token-endpoint-auth-method none \
|
--token-endpoint-auth-method none \
|
||||||
--redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}
|
--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 \
|
hydra create oauth2-client \
|
||||||
--endpoint http://hydra:4445 \
|
--endpoint http://hydra:4445 \
|
||||||
--id ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} \
|
--id ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} \
|
||||||
|
|||||||
@@ -86,6 +86,27 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- baron_net
|
- 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:
|
userfront:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
Reference in New Issue
Block a user