forked from baron/baron-sso
fix: align local Ory cookie domain rendering
This commit is contained in:
5
Makefile
5
Makefile
@@ -56,6 +56,7 @@ up: up-all
|
|||||||
up-all: ensure-networks render-ory-config
|
up-all: ensure-networks render-ory-config
|
||||||
@echo "Starting ALL stacks (infra + ory + app)..."
|
@echo "Starting ALL stacks (infra + ory + app)..."
|
||||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
|
||||||
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
|
||||||
|
|
||||||
# --- 개별 스택 실행 ---
|
# --- 개별 스택 실행 ---
|
||||||
up-infra: ensure-networks
|
up-infra: ensure-networks
|
||||||
@@ -65,6 +66,7 @@ up-infra: ensure-networks
|
|||||||
up-ory: ensure-networks render-ory-config
|
up-ory: ensure-networks render-ory-config
|
||||||
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
||||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
|
||||||
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
|
||||||
|
|
||||||
up-app: ensure-networks render-ory-config
|
up-app: ensure-networks render-ory-config
|
||||||
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
|
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
|
||||||
@@ -114,7 +116,8 @@ ensure-ory: ensure-networks render-ory-config
|
|||||||
echo "Starting missing Ory stack containers in daemon mode..."; \
|
echo "Starting missing Ory stack containers in daemon mode..."; \
|
||||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
|
||||||
else \
|
else \
|
||||||
echo "Ory stack is already running."; \
|
echo "Ory stack is already running. Restarting Kratos to apply rendered dev config..."; \
|
||||||
|
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
up-dev: ensure-infra ensure-ory
|
up-dev: ensure-infra ensure-ory
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ serve:
|
|||||||
|
|
||||||
session:
|
session:
|
||||||
cookie:
|
cookie:
|
||||||
domain: hmac.kr
|
domain: ${KRATOS_SESSION_COOKIE_DOMAIN}
|
||||||
same_site: Lax
|
same_site: Lax
|
||||||
path: /
|
path: /
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ serve:
|
|||||||
|
|
||||||
session:
|
session:
|
||||||
cookie:
|
cookie:
|
||||||
domain: hmac.kr
|
domain: ${KRATOS_SESSION_COOKIE_DOMAIN}
|
||||||
same_site: Lax
|
same_site: Lax
|
||||||
path: /
|
path: /
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:org-context-chart": "npm run build:org-context-chart:full && npm run build:org-context-chart:min",
|
||||||
|
"build:org-context-chart:full": "vite build --config vite.org-context-chart.config.ts",
|
||||||
|
"build:org-context-chart:min": "ORG_CONTEXT_CHART_MINIFY=true vite build --config vite.org-context-chart.config.ts",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
|
|||||||
491
orgfront/src/sdk/org-context-chart/index.ts
Normal file
491
orgfront/src/sdk/org-context-chart/index.ts
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
export type OrgContextMember = {
|
||||||
|
id?: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
department?: string;
|
||||||
|
grade?: string;
|
||||||
|
position?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
isOwner?: boolean;
|
||||||
|
isLeader?: boolean;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgContextTenant = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
status: string;
|
||||||
|
description: string;
|
||||||
|
domains: string[];
|
||||||
|
memberCount: number;
|
||||||
|
visibility: string;
|
||||||
|
orgUnitType?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
members: OrgContextMember[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgContextTreeNode = OrgContextTenant & {
|
||||||
|
children: OrgContextTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgContextResponse = {
|
||||||
|
schemaVersion: "baron.org-context.v1";
|
||||||
|
issuedAt: string;
|
||||||
|
scope: {
|
||||||
|
tenantId: string;
|
||||||
|
tenantSlug: string;
|
||||||
|
};
|
||||||
|
tree: OrgContextTreeNode;
|
||||||
|
tenants: OrgContextTenant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgChartNode = OrgContextTenant & {
|
||||||
|
children: OrgChartNode[];
|
||||||
|
depth: number;
|
||||||
|
path: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgChartMember = OrgContextMember & {
|
||||||
|
tenantIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgChartModel = {
|
||||||
|
root: OrgChartNode;
|
||||||
|
nodes: OrgChartNode[];
|
||||||
|
tenantsById: Map<string, OrgChartNode>;
|
||||||
|
tenantsBySlug: Map<string, OrgChartNode>;
|
||||||
|
membersByEmail: Map<string, OrgChartMember>;
|
||||||
|
response: OrgContextResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgContextClientOptions = {
|
||||||
|
baseUrl: string;
|
||||||
|
credentials?: {
|
||||||
|
keyId: string;
|
||||||
|
keySecret: string;
|
||||||
|
};
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchOrgContextOptions = {
|
||||||
|
tenantSlug?: string;
|
||||||
|
includeUsers?: boolean;
|
||||||
|
includeUserIds?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgPickerSelection = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "tenant" | "user";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgPickerOptions = {
|
||||||
|
mode?: "single" | "multiple";
|
||||||
|
selectable?: "tenant" | "user" | "both";
|
||||||
|
includeDescendants?: boolean;
|
||||||
|
onChange?: (selection: OrgPickerSelection[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrgPickerController = {
|
||||||
|
destroy: () => void;
|
||||||
|
getSelection: () => OrgPickerSelection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_PATH = "/api/v1/integrations/org-context";
|
||||||
|
|
||||||
|
export function createOrgContextClient(options: OrgContextClientOptions) {
|
||||||
|
const fetcher = options.fetch ?? globalThis.fetch;
|
||||||
|
if (!fetcher) {
|
||||||
|
throw new Error("A fetch implementation is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async fetchOrgContext(
|
||||||
|
query: FetchOrgContextOptions = {},
|
||||||
|
): Promise<OrgContextResponse> {
|
||||||
|
const url = new URL(API_PATH, normalizeBaseUrl(options.baseUrl));
|
||||||
|
appendQuery(url, "tenantSlug", query.tenantSlug);
|
||||||
|
appendQuery(url, "includeUsers", query.includeUsers);
|
||||||
|
appendQuery(url, "includeUserIds", query.includeUserIds);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json",
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
if (options.credentials) {
|
||||||
|
headers["X-Baron-Key-ID"] = options.credentials.keyId;
|
||||||
|
headers["X-Baron-Key-Secret"] = options.credentials.keySecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetcher(url.toString(), { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Org context request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as OrgContextResponse;
|
||||||
|
if (payload.schemaVersion !== "baron.org-context.v1") {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported org context schema: ${payload.schemaVersion}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrgChartModel(
|
||||||
|
response: OrgContextResponse,
|
||||||
|
): OrgChartModel {
|
||||||
|
if (response.schemaVersion !== "baron.org-context.v1") {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported org context schema: ${response.schemaVersion}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: OrgChartNode[] = [];
|
||||||
|
const tenantsById = new Map<string, OrgChartNode>();
|
||||||
|
const tenantsBySlug = new Map<string, OrgChartNode>();
|
||||||
|
const membersByEmail = new Map<string, OrgChartMember>();
|
||||||
|
|
||||||
|
const visit = (
|
||||||
|
node: OrgContextTreeNode,
|
||||||
|
depth: number,
|
||||||
|
ancestorPath: string[],
|
||||||
|
): OrgChartNode => {
|
||||||
|
const path = [...ancestorPath, node.id];
|
||||||
|
const chartNode: OrgChartNode = {
|
||||||
|
...node,
|
||||||
|
children: [],
|
||||||
|
depth,
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
nodes.push(chartNode);
|
||||||
|
tenantsById.set(chartNode.id, chartNode);
|
||||||
|
tenantsBySlug.set(chartNode.slug, chartNode);
|
||||||
|
|
||||||
|
for (const member of chartNode.members) {
|
||||||
|
const key = member.email.toLowerCase();
|
||||||
|
const existing = membersByEmail.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.tenantIds.push(chartNode.id);
|
||||||
|
} else {
|
||||||
|
membersByEmail.set(key, {
|
||||||
|
...member,
|
||||||
|
tenantIds: [chartNode.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chartNode.children = node.children.map((child) =>
|
||||||
|
visit(child, depth + 1, path),
|
||||||
|
);
|
||||||
|
return chartNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: visit(response.tree, 0, []),
|
||||||
|
nodes,
|
||||||
|
tenantsById,
|
||||||
|
tenantsBySlug,
|
||||||
|
membersByEmail,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOrgChart(
|
||||||
|
container: HTMLElement,
|
||||||
|
model: OrgChartModel,
|
||||||
|
): { destroy: () => void } {
|
||||||
|
container.replaceChildren();
|
||||||
|
container.classList.add("baron-org-chart");
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "baron-org-chart__tree";
|
||||||
|
root.append(renderChartNode(model.root));
|
||||||
|
container.append(root);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
container.replaceChildren();
|
||||||
|
container.classList.remove("baron-org-chart");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOrgPicker(
|
||||||
|
container: HTMLElement,
|
||||||
|
model: OrgChartModel,
|
||||||
|
options: OrgPickerOptions = {},
|
||||||
|
): OrgPickerController {
|
||||||
|
const mode = options.mode ?? "single";
|
||||||
|
const selectable = options.selectable ?? "tenant";
|
||||||
|
const includeDescendants = options.includeDescendants ?? false;
|
||||||
|
const selected = new Map<string, OrgPickerSelection>();
|
||||||
|
|
||||||
|
const emitChange = () => {
|
||||||
|
const selection = Array.from(selected.values());
|
||||||
|
options.onChange?.(selection);
|
||||||
|
container.dispatchEvent(
|
||||||
|
new CustomEvent("baron-org-picker-change", {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { selection },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelection = (
|
||||||
|
selection: OrgPickerSelection,
|
||||||
|
checked: boolean,
|
||||||
|
descendants: OrgPickerSelection[],
|
||||||
|
) => {
|
||||||
|
if (mode === "single") {
|
||||||
|
selected.clear();
|
||||||
|
if (checked) {
|
||||||
|
selected.set(selectionKey(selection), selection);
|
||||||
|
}
|
||||||
|
emitChange();
|
||||||
|
rerender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets =
|
||||||
|
includeDescendants && selection.type === "tenant"
|
||||||
|
? [selection, ...descendants]
|
||||||
|
: [selection];
|
||||||
|
for (const target of targets) {
|
||||||
|
if (checked) {
|
||||||
|
selected.set(selectionKey(target), target);
|
||||||
|
} else {
|
||||||
|
selected.delete(selectionKey(target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitChange();
|
||||||
|
rerender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPickerNode = (node: OrgChartNode): HTMLElement => {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.className = "baron-org-picker__item";
|
||||||
|
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "baron-org-picker__row";
|
||||||
|
row.style.paddingLeft = `${node.depth * 16}px`;
|
||||||
|
|
||||||
|
const tenantSelection: OrgPickerSelection = {
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
type: "tenant",
|
||||||
|
};
|
||||||
|
if (selectable === "tenant" || selectable === "both") {
|
||||||
|
row.append(
|
||||||
|
createPickerInput({
|
||||||
|
mode,
|
||||||
|
selection: tenantSelection,
|
||||||
|
selected,
|
||||||
|
onToggle: (checked) =>
|
||||||
|
toggleSelection(
|
||||||
|
tenantSelection,
|
||||||
|
checked,
|
||||||
|
collectDescendantSelections(node, selectable),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
row.append(createLabelText(node.name, node.type));
|
||||||
|
item.append(row);
|
||||||
|
|
||||||
|
if (selectable === "user" || selectable === "both") {
|
||||||
|
for (const member of node.members) {
|
||||||
|
item.append(
|
||||||
|
renderMemberPickerRow(member, node, mode, selected, (value) =>
|
||||||
|
toggleSelection(value, !selected.has(selectionKey(value)), []),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
const children = document.createElement("ul");
|
||||||
|
children.className = "baron-org-picker__children";
|
||||||
|
for (const child of node.children) {
|
||||||
|
children.append(renderPickerNode(child));
|
||||||
|
}
|
||||||
|
item.append(children);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rerender = () => {
|
||||||
|
container.replaceChildren();
|
||||||
|
container.classList.add("baron-org-picker");
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "baron-org-picker__list";
|
||||||
|
list.append(renderPickerNode(model.root));
|
||||||
|
container.append(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
container.replaceChildren();
|
||||||
|
container.classList.remove("baron-org-picker");
|
||||||
|
selected.clear();
|
||||||
|
},
|
||||||
|
getSelection() {
|
||||||
|
return Array.from(selected.values());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQuery(
|
||||||
|
url: URL,
|
||||||
|
key: string,
|
||||||
|
value: string | boolean | undefined,
|
||||||
|
) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string) {
|
||||||
|
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChartNode(node: OrgChartNode): HTMLElement {
|
||||||
|
const item = document.createElement("section");
|
||||||
|
item.className = "baron-org-chart__node";
|
||||||
|
item.dataset.baronOrgNode = node.id;
|
||||||
|
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.className = "baron-org-chart__title";
|
||||||
|
title.textContent = node.name;
|
||||||
|
item.append(title);
|
||||||
|
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "baron-org-chart__meta";
|
||||||
|
meta.textContent = [node.type, node.orgUnitType, node.visibility]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
item.append(meta);
|
||||||
|
|
||||||
|
if (node.members.length > 0) {
|
||||||
|
const memberList = document.createElement("ul");
|
||||||
|
memberList.className = "baron-org-chart__members";
|
||||||
|
for (const member of node.members) {
|
||||||
|
const memberItem = document.createElement("li");
|
||||||
|
memberItem.textContent = formatMember(member);
|
||||||
|
memberList.append(memberItem);
|
||||||
|
}
|
||||||
|
item.append(memberList);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
const children = document.createElement("div");
|
||||||
|
children.className = "baron-org-chart__children";
|
||||||
|
for (const child of node.children) {
|
||||||
|
children.append(renderChartNode(child));
|
||||||
|
}
|
||||||
|
item.append(children);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMemberPickerRow(
|
||||||
|
member: OrgContextMember,
|
||||||
|
node: OrgChartNode,
|
||||||
|
mode: "single" | "multiple",
|
||||||
|
selected: Map<string, OrgPickerSelection>,
|
||||||
|
onSelect: (selection: OrgPickerSelection) => void,
|
||||||
|
) {
|
||||||
|
const selection: OrgPickerSelection = {
|
||||||
|
id: member.id || `${node.id}:${member.email}`,
|
||||||
|
name: member.name,
|
||||||
|
type: "user",
|
||||||
|
};
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "baron-org-picker__row baron-org-picker__row--member";
|
||||||
|
row.style.paddingLeft = `${node.depth * 16 + 24}px`;
|
||||||
|
row.append(
|
||||||
|
createPickerInput({
|
||||||
|
mode,
|
||||||
|
selection,
|
||||||
|
selected,
|
||||||
|
onToggle: () => onSelect(selection),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
row.append(createLabelText(member.name, member.email));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPickerInput({
|
||||||
|
mode,
|
||||||
|
selection,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
mode: "single" | "multiple";
|
||||||
|
selection: OrgPickerSelection;
|
||||||
|
selected: Map<string, OrgPickerSelection>;
|
||||||
|
onToggle: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = mode === "single" ? "radio" : "checkbox";
|
||||||
|
input.name = "baron-org-picker";
|
||||||
|
input.value = selectionKey(selection);
|
||||||
|
input.checked = selected.has(selectionKey(selection));
|
||||||
|
const handleToggle = () => {
|
||||||
|
onToggle(mode === "single" ? true : !selected.has(selectionKey(selection)));
|
||||||
|
};
|
||||||
|
input.addEventListener("click", handleToggle);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLabelText(primary: string, secondary?: string) {
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "baron-org-picker__label";
|
||||||
|
text.textContent = secondary ? `${primary} (${secondary})` : primary;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDescendantSelections(
|
||||||
|
node: OrgChartNode,
|
||||||
|
selectable: "tenant" | "user" | "both",
|
||||||
|
): OrgPickerSelection[] {
|
||||||
|
const selections: OrgPickerSelection[] = [];
|
||||||
|
const visit = (child: OrgChartNode) => {
|
||||||
|
if (selectable === "tenant" || selectable === "both") {
|
||||||
|
selections.push({ id: child.id, name: child.name, type: "tenant" });
|
||||||
|
}
|
||||||
|
if (selectable === "user" || selectable === "both") {
|
||||||
|
for (const member of child.members) {
|
||||||
|
selections.push({
|
||||||
|
id: member.id || `${child.id}:${member.email}`,
|
||||||
|
name: member.name,
|
||||||
|
type: "user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const grandchild of child.children) {
|
||||||
|
visit(grandchild);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const child of node.children) {
|
||||||
|
visit(child);
|
||||||
|
}
|
||||||
|
return selections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionKey(selection: OrgPickerSelection) {
|
||||||
|
return `${selection.type}:${selection.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMember(member: OrgContextMember) {
|
||||||
|
return [member.name, member.position, member.jobTitle]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
171
orgfront/src/sdk/org-context-chart/orgContextChart.test.ts
Normal file
171
orgfront/src/sdk/org-context-chart/orgContextChart.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
type OrgContextResponse,
|
||||||
|
buildOrgChartModel,
|
||||||
|
createOrgContextClient,
|
||||||
|
renderOrgChart,
|
||||||
|
renderOrgPicker,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
|
const sampleOrgContext: OrgContextResponse = {
|
||||||
|
schemaVersion: "baron.org-context.v1",
|
||||||
|
issuedAt: "2026-05-15T00:00:00Z",
|
||||||
|
scope: {
|
||||||
|
tenantId: "root",
|
||||||
|
tenantSlug: "hanmac-family",
|
||||||
|
},
|
||||||
|
tree: {
|
||||||
|
id: "root",
|
||||||
|
type: "COMPANY_GROUP",
|
||||||
|
name: "한맥가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
status: "active",
|
||||||
|
description: "",
|
||||||
|
domains: [],
|
||||||
|
memberCount: 0,
|
||||||
|
visibility: "public",
|
||||||
|
createdAt: "2026-05-15T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-15T00:00:00Z",
|
||||||
|
members: [],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "company-baron",
|
||||||
|
type: "COMPANY",
|
||||||
|
name: "Baron",
|
||||||
|
slug: "baron",
|
||||||
|
parentId: "root",
|
||||||
|
status: "active",
|
||||||
|
description: "",
|
||||||
|
domains: ["baron.example"],
|
||||||
|
memberCount: 1,
|
||||||
|
visibility: "public",
|
||||||
|
createdAt: "2026-05-15T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-15T00:00:00Z",
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
email: "leader@example.com",
|
||||||
|
name: "Leader",
|
||||||
|
grade: "책임",
|
||||||
|
position: "팀장",
|
||||||
|
isLeader: true,
|
||||||
|
isOwner: true,
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "team-platform",
|
||||||
|
type: "USER_GROUP",
|
||||||
|
name: "Platform",
|
||||||
|
slug: "platform",
|
||||||
|
parentId: "company-baron",
|
||||||
|
status: "active",
|
||||||
|
description: "",
|
||||||
|
domains: [],
|
||||||
|
memberCount: 1,
|
||||||
|
visibility: "internal",
|
||||||
|
orgUnitType: "팀",
|
||||||
|
createdAt: "2026-05-15T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-15T00:00:00Z",
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
email: "engineer@example.com",
|
||||||
|
name: "Engineer",
|
||||||
|
jobTitle: "Frontend Engineer",
|
||||||
|
isLeader: false,
|
||||||
|
isOwner: false,
|
||||||
|
isPrimary: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tenants: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("org-context chart SDK", () => {
|
||||||
|
it("builds chart and lookup models from org-context v1", () => {
|
||||||
|
const model = buildOrgChartModel(sampleOrgContext);
|
||||||
|
|
||||||
|
expect(model.root.name).toBe("한맥가족");
|
||||||
|
expect(model.nodes).toHaveLength(3);
|
||||||
|
expect(model.tenantsBySlug.get("platform")?.orgUnitType).toBe("팀");
|
||||||
|
expect(model.membersByEmail.get("engineer@example.com")?.tenantIds).toEqual(
|
||||||
|
["team-platform"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches org-context through authenticated API headers", async () => {
|
||||||
|
const fetcher = vi.fn(async () => {
|
||||||
|
return new Response(JSON.stringify(sampleOrgContext), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const client = createOrgContextClient({
|
||||||
|
baseUrl: "https://sso.example.com",
|
||||||
|
credentials: {
|
||||||
|
keyId: "client-id",
|
||||||
|
keySecret: "client-secret",
|
||||||
|
},
|
||||||
|
fetch: fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.fetchOrgContext({
|
||||||
|
tenantSlug: "baron",
|
||||||
|
includeUsers: true,
|
||||||
|
includeUserIds: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, init] = fetcher.mock.calls[0];
|
||||||
|
expect(String(url)).toBe(
|
||||||
|
"https://sso.example.com/api/v1/integrations/org-context?tenantSlug=baron&includeUsers=true&includeUserIds=false",
|
||||||
|
);
|
||||||
|
expect(init.headers).toMatchObject({
|
||||||
|
"X-Baron-Key-ID": "client-id",
|
||||||
|
"X-Baron-Key-Secret": "client-secret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders chart and picker DOM with selection events", () => {
|
||||||
|
const model = buildOrgChartModel(sampleOrgContext);
|
||||||
|
const chartContainer = document.createElement("div");
|
||||||
|
const pickerContainer = document.createElement("div");
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
renderOrgChart(chartContainer, model);
|
||||||
|
const picker = renderOrgPicker(pickerContainer, model, {
|
||||||
|
mode: "multiple",
|
||||||
|
selectable: "both",
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
chartContainer.querySelectorAll("[data-baron-org-node]"),
|
||||||
|
).toHaveLength(3);
|
||||||
|
const platformCheckbox = pickerContainer.querySelector<HTMLInputElement>(
|
||||||
|
'input[value="tenant:team-platform"]',
|
||||||
|
);
|
||||||
|
expect(platformCheckbox).not.toBeNull();
|
||||||
|
|
||||||
|
platformCheckbox?.click();
|
||||||
|
|
||||||
|
expect(picker.getSelection()).toEqual([
|
||||||
|
{
|
||||||
|
id: "team-platform",
|
||||||
|
name: "Platform",
|
||||||
|
type: "tenant",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(onChange).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
id: "team-platform",
|
||||||
|
name: "Platform",
|
||||||
|
type: "tenant",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,5 +22,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "vite.org-context-chart.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
25
orgfront/vite.org-context-chart.config.ts
Normal file
25
orgfront/vite.org-context-chart.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
const isMinifiedBuild = process.env.ORG_CONTEXT_CHART_MINIFY === "true";
|
||||||
|
const fileSuffix = isMinifiedBuild ? ".min" : "";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
emptyOutDir: !isMinifiedBuild,
|
||||||
|
lib: {
|
||||||
|
entry: fileURLToPath(
|
||||||
|
new URL("./src/sdk/org-context-chart/index.ts", import.meta.url),
|
||||||
|
),
|
||||||
|
fileName: (format) =>
|
||||||
|
format === "es"
|
||||||
|
? `baron-org-context-chart${fileSuffix}.js`
|
||||||
|
: `baron-org-context-chart${fileSuffix}.umd.cjs`,
|
||||||
|
formats: ["es", "umd"],
|
||||||
|
name: "BaronOrgContextChart",
|
||||||
|
},
|
||||||
|
minify: isMinifiedBuild,
|
||||||
|
outDir: "dist/org-context-chart",
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -62,6 +62,58 @@ append_unique_url() {
|
|||||||
KRATOS_ALLOWED_RETURN_URLS+=("$candidate")
|
KRATOS_ALLOWED_RETURN_URLS+=("$candidate")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
url_host() {
|
||||||
|
local url="${1:-}"
|
||||||
|
[[ -n "$url" ]] || return 0
|
||||||
|
|
||||||
|
local without_scheme="$url"
|
||||||
|
if [[ "$without_scheme" == *"://"* ]]; then
|
||||||
|
without_scheme="${without_scheme#*://}"
|
||||||
|
fi
|
||||||
|
without_scheme="${without_scheme%%/*}"
|
||||||
|
without_scheme="${without_scheme%%\?*}"
|
||||||
|
without_scheme="${without_scheme%%#*}"
|
||||||
|
|
||||||
|
if [[ "$without_scheme" == \[*\]* ]]; then
|
||||||
|
without_scheme="${without_scheme#[}"
|
||||||
|
without_scheme="${without_scheme%%]*}"
|
||||||
|
elif [[ "$without_scheme" == *:* ]]; then
|
||||||
|
without_scheme="${without_scheme%%:*}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$without_scheme"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_kratos_session_cookie_domain() {
|
||||||
|
if [[ -n "${KRATOS_SESSION_COOKIE_DOMAIN:-}" ]]; then
|
||||||
|
export KRATOS_SESSION_COOKIE_DOMAIN
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local public_host
|
||||||
|
public_host="$(url_host "${KRATOS_BROWSER_URL:-}")"
|
||||||
|
if [[ -z "$public_host" ]]; then
|
||||||
|
public_host="$(url_host "${KRATOS_UI_URL:-}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$public_host" in
|
||||||
|
localhost|127.0.0.1|0.0.0.0|*.localhost)
|
||||||
|
KRATOS_SESSION_COOKIE_DOMAIN="localhost"
|
||||||
|
;;
|
||||||
|
*.hmac.kr|hmac.kr)
|
||||||
|
KRATOS_SESSION_COOKIE_DOMAIN="hmac.kr"
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
KRATOS_SESSION_COOKIE_DOMAIN="localhost"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
KRATOS_SESSION_COOKIE_DOMAIN="$public_host"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export KRATOS_SESSION_COOKIE_DOMAIN
|
||||||
|
}
|
||||||
|
|
||||||
build_kratos_allowed_return_urls_yaml() {
|
build_kratos_allowed_return_urls_yaml() {
|
||||||
KRATOS_ALLOWED_RETURN_URLS=()
|
KRATOS_ALLOWED_RETURN_URLS=()
|
||||||
if [[ -n "${KRATOS_ALLOWED_RETURN_URLS_JSON:-}" ]]; then
|
if [[ -n "${KRATOS_ALLOWED_RETURN_URLS_JSON:-}" ]]; then
|
||||||
@@ -137,6 +189,7 @@ OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oath
|
|||||||
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET
|
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET
|
||||||
export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
|
export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
|
||||||
|
|
||||||
|
resolve_kratos_session_cookie_domain
|
||||||
build_kratos_allowed_return_urls_yaml
|
build_kratos_allowed_return_urls_yaml
|
||||||
|
|
||||||
mkdir -p "$OUTPUT_DIR/kratos" "$OUTPUT_DIR/hydra" "$OUTPUT_DIR/keto" "$OUTPUT_DIR/oathkeeper"
|
mkdir -p "$OUTPUT_DIR/kratos" "$OUTPUT_DIR/hydra" "$OUTPUT_DIR/keto" "$OUTPUT_DIR/oathkeeper"
|
||||||
|
|||||||
22
test/orgfront_org_context_chart_package_test.sh
Normal file
22
test/orgfront_org_context_chart_package_test.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "[org-context-chart-package] $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local file="$1"
|
||||||
|
local needle="$2"
|
||||||
|
grep -Fq "$needle" "$file" || fail "$file must contain: $needle"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains orgfront/package.json "build:org-context-chart:min"
|
||||||
|
assert_contains orgfront/vite.org-context-chart.config.ts "ORG_CONTEXT_CHART_MINIFY"
|
||||||
|
assert_contains orgfront/vite.org-context-chart.config.ts ".min"
|
||||||
|
|
||||||
|
echo "OK: OrgContext chart package emits explicit minified bundles"
|
||||||
@@ -268,6 +268,18 @@ if ! grep -q '^render-ory-config:' "$repo_root/Makefile"; then
|
|||||||
echo "ERROR: Makefile must render Ory config before starting Ory services." >&2
|
echo "ERROR: Makefile must render Ory config before starting Ory services." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! awk '/^ensure-ory:/ { in_target=1 } in_target && /^[^[:space:]].*:/ && $0 !~ /^ensure-ory:/ { exit } in_target { print }' "$repo_root/Makefile" | grep -q 'restart kratos'; then
|
||||||
|
echo "ERROR: make up-dev must restart Kratos when Ory is already running so rendered dev config is applied." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! awk '/^up-all:/ { in_target=1 } in_target && /^[^[:space:]].*:/ && $0 !~ /^up-all:/ { exit } in_target { print }' "$repo_root/Makefile" | grep -q 'restart kratos'; then
|
||||||
|
echo "ERROR: make up must restart Kratos after rendering Ory config." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! awk '/^up-ory:/ { in_target=1 } in_target && /^[^[:space:]].*:/ && $0 !~ /^up-ory:/ { exit } in_target { print }' "$repo_root/Makefile" | grep -q 'restart kratos'; then
|
||||||
|
echo "ERROR: make up-ory must restart Kratos after rendering Ory config." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
|
if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
|
||||||
echo "ERROR: staging code pull must render Ory config before docker compose up." >&2
|
echo "ERROR: staging code pull must render Ory config before docker compose up." >&2
|
||||||
@@ -285,6 +297,11 @@ if grep -Eq '^[[:space:]]*rm -rf "?\$OUTPUT_DIR"?[[:space:]]*$' "$repo_root/scri
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
"$repo_root/scripts/render_ory_config.sh" >/dev/null
|
"$repo_root/scripts/render_ory_config.sh" >/dev/null
|
||||||
|
local_rendered_kratos="$repo_root/config/.generated/ory/kratos/kratos.yml"
|
||||||
|
if ! awk '/session:/ { in_session=1 } in_session && /domain:/ { print; exit }' "$local_rendered_kratos" | grep -q 'domain: localhost'; then
|
||||||
|
echo "ERROR: rendered local Kratos config must use localhost as session.cookie.domain for dev runs." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
stage_render_dir="$(mktemp -d)"
|
stage_render_dir="$(mktemp -d)"
|
||||||
stage_render_env="$(mktemp)"
|
stage_render_env="$(mktemp)"
|
||||||
@@ -310,6 +327,10 @@ if awk '/allowed_return_urls:/ { in_block=1; next } in_block && /^[[:space:]]+me
|
|||||||
echo "ERROR: rendered stage Kratos allowed_return_urls must not fall back to localhost." >&2
|
echo "ERROR: rendered stage Kratos allowed_return_urls must not fall back to localhost." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! awk '/session:/ { in_session=1 } in_session && /domain:/ { print; exit }' "$stage_rendered_kratos" | grep -q 'domain: hmac.kr'; then
|
||||||
|
echo "ERROR: rendered stage Kratos config must derive hmac.kr as session.cookie.domain." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
rm -rf "$stage_render_dir" "$stage_render_env"
|
rm -rf "$stage_render_dir" "$stage_render_env"
|
||||||
|
|
||||||
for generated_config in \
|
for generated_config in \
|
||||||
|
|||||||
Reference in New Issue
Block a user