1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/rbac-simplification-and-remove-dev-switcher

This commit is contained in:
2026-06-02 18:36:44 +09:00
88 changed files with 7453 additions and 2180 deletions

View File

@@ -280,7 +280,11 @@ code-check-front-lint:
cd adminfront && npx biome format .
@echo "==> devfront biome lint/format check"
rm -rf devfront/playwright-report devfront/test-results
cd devfront && npm ci --ignore-scripts
@if [ -d devfront/node_modules ]; then \
echo "devfront/node_modules already present; skipping npm install."; \
else \
cd devfront && npm ci --ignore-scripts; \
fi
cd devfront && npx biome lint .
cd devfront && npx biome format .
@echo "==> orgfront biome lint/format check"
@@ -324,7 +328,14 @@ code-check-devfront-tests:
@mkdir -p reports/devfront
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
@status=0; \
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
preview_pattern='[v]ite preview --host 127.0.0.1 --strictPort --port 4174'; \
pkill -f "$$preview_pattern" >/dev/null 2>&1 || true; \
trap 'pkill -f "$$preview_pattern" >/dev/null 2>&1 || true' EXIT INT TERM; \
if [ -d devfront/node_modules ]; then \
echo "devfront/node_modules already present; skipping npm install."; \
else \
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
@@ -388,7 +399,7 @@ code-check-userfront-e2e-tests:
(cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \

View File

@@ -0,0 +1,7 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.5556 0H6.44444C2.88528 0 0 2.88528 0 6.44444V23.5556C0 27.1147 2.88528 30 6.44444 30H23.5556C27.1147 30 30 27.1147 30 23.5556V6.44444C30 2.88528 27.1147 0 23.5556 0Z" fill="white"/>
<path d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z" fill="#028B3A"/>
<path d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z" fill="#88E518"/>
<path d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z" fill="#7EE3A1"/>
<path d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z" fill="#03C75A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -1,11 +1,12 @@
id,name,type,parent_tenant_slug,slug,memo,email_domain
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,baron-group,hanlla,,hanllasanup.co.kr
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,
id,name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,,,,
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr,,,
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com,,,
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr,,,
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,brsw.kr,,,
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,hanmac-family,halla,네이버웍스 한라 HALLA_DOMAIN_ID,hallasanup.com,,,
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com,,,
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,,
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,,
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,,
1 id name type parent_tenant_slug slug memo email_domain visibility org_unit_type worksmobile_sync
2 038326b6-954a-48a7-a85f-efd83f62b82a 한맥가족 COMPANY_GROUP hanmac-family 한맥가족 기본 루트 테넌트
3 9caf62e1-297d-4e8f-870b-61780998bbeb 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee 삼안 총괄기획&기술개발센터 COMPANY hanmac-family saman gpdtdc 네이버웍스 삼안 SAMAN_DOMAIN_ID 네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID samaneng.com baroncs.co.kr
4 369c1843-56af-4344-9c21-0e01197ab861 9caf62e1-297d-4e8f-870b-61780998bbeb 한맥기술 삼안 COMPANY hanmac-family hanmac saman 네이버웍스 한맥 HANMAC_DOMAIN_ID 네이버웍스 삼안 SAMAN_DOMAIN_ID hanmaceng.co.kr samaneng.com
5 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee 369c1843-56af-4344-9c21-0e01197ab861 총괄기획&기술개발센터 한맥기술 COMPANY hanmac-family gpdtdc hanmac 네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID 네이버웍스 한맥 HANMAC_DOMAIN_ID baroncs.co.kr hanmaceng.co.kr
6 96369f12-6b66-4b2a-a916-d1c99d326f02 바론그룹 COMPANY_GROUP hanmac-family baron-group 네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID brsw.kr
7 c18a8284-0008-48aa-9cdf-9f47ab79a2a9 5a03efd2-e62f-4243-800d-58334bf48b2f (주)장헌 한라산업개발 COMPANY baron-group hanmac-family jangheon halla 네이버웍스 한라 HALLA_DOMAIN_ID jangheon.com hallasanup.com
8 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 c18a8284-0008-48aa-9cdf-9f47ab79a2a9 장헌산업 (주)장헌 COMPANY baron-group jangheon-sanup jangheon jangheon.co.kr jangheon.com
9 5a03efd2-e62f-4243-800d-58334bf48b2f b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 한라산업개발 장헌산업 COMPANY baron-group hanlla jangheon-sanup hanllasanup.co.kr jangheon.co.kr
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 9607eb7b-04d2-42ab-80fe-780fe21c7e8f 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7 Personal MH_manager PERSONAL USER_GROUP hanmac-family personal mhd 개인 사용자 기본 루트 테넌트 맨아워 대시보드 권한 보유자그룹 private no
12 9607eb7b-04d2-42ab-80fe-780fe21c7e8f Personal PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

@@ -48,6 +48,7 @@ export const adminRoutes: RouteObject[] = [
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
@@ -56,7 +57,6 @@ export const adminRoutes: RouteObject[] = [
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
],
},
{

View File

@@ -70,6 +70,7 @@ function renderLayout(entry = "/users") {
path="tenants/:tenantId"
element={<div>Tenant outlet</div>}
/>
<Route path="worksmobile" element={<div>Worksmobile outlet</div>} />
<Route path="login" element={<div>Login outlet</div>} />
</Route>
</Routes>
@@ -99,7 +100,28 @@ describe("admin AppLayout", () => {
expect(screen.getByText("Admin Control")).toBeInTheDocument();
expect(screen.getByText("Users outlet")).toBeInTheDocument();
expect(screen.getByText("Tenants")).toBeInTheDocument();
expect(screen.getByText("Worksmobile")).toBeInTheDocument();
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
expect(screen.queryByText("User Projection")).not.toBeInTheDocument();
const navigation = screen.getByRole("navigation");
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
link.textContent?.trim(),
);
expect(navLabels).toEqual([
"Overview",
"Tenants",
"Worksmobile",
"Users",
"Data Integrity",
"Auth Guard",
"API Keys",
"Audit Logs",
]);
const worksmobileIcon = screen.getByTestId("worksmobile-nav-icon");
expect(worksmobileIcon.tagName.toLowerCase()).toBe("svg");
expect(worksmobileIcon).toHaveAttribute("fill", "none");
expect(worksmobileIcon.querySelectorAll("path")).toHaveLength(4);
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
});
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {

View File

@@ -2,13 +2,11 @@ import { useQuery } from "@tanstack/react-query";
import {
Building2,
ChevronDown,
Database,
Key,
KeyRound,
LayoutDashboard,
LogOut,
Moon,
Network,
NotebookTabs,
ShieldCheck,
ShieldHalf,
@@ -32,7 +30,7 @@ import {
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
import { fetchMe } from "../../lib/adminApi";
import { debugLog } from "../../lib/debugLog";
import { t } from "../../lib/i18n";
@@ -59,6 +57,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
to: "/users",
icon: Users,
},
{
labelKey: "ui.admin.nav.auth_guard",
labelFallback: "Auth Guard",
to: "/auth",
icon: KeyRound,
},
{
labelKey: "ui.admin.nav.api_keys",
labelFallback: "API Keys",
@@ -71,12 +75,6 @@ const staticNavItems: ShellSidebarNavItem[] = [
to: "/audit-logs",
icon: NotebookTabs,
},
{
labelKey: "ui.admin.nav.auth_guard",
labelFallback: "Auth Guard",
to: "/auth",
icon: KeyRound,
},
];
type SessionStatusProps = {
@@ -121,6 +119,38 @@ function SessionStatusText(props: SessionStatusProps) {
return <>{sessionStatus.text}</>;
}
function LineWorksNavIcon({ size = 18 }: { size?: number | string }) {
const iconSize = typeof size === "number" ? size : Number.parseFloat(size);
return (
<svg
aria-hidden="true"
data-testid="worksmobile-nav-icon"
width={Number.isFinite(iconSize) ? iconSize : size}
height={Number.isFinite(iconSize) ? iconSize : size}
viewBox="0 0 30 30"
fill="none"
className="shrink-0 text-current"
>
<path
d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z"
fill="currentColor"
/>
<path
d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z"
fill="currentColor"
/>
<path
d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z"
fill="currentColor"
/>
<path
d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z"
fill="currentColor"
/>
</svg>
);
}
function AppLayout() {
const auth = useAuth();
const location = useLocation();
@@ -164,11 +194,10 @@ function AppLayout() {
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
const manageableCount = profile?.manageableTenants?.length ?? 0;
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
{ includeInternal: true },
);
const showWorksmobile = canAccessWorksmobile({
...profile,
role: effectiveRole ?? profile?.role,
});
const filteredItems = items.filter((item) => {
if (isTest) return true;
if (item.to === "/api-keys") return isSuperAdmin;
@@ -182,20 +211,15 @@ function AppLayout() {
to: "/tenants",
icon: Building2,
});
filteredItems.splice(2, 0, {
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
if (showWorksmobile) {
filteredItems.splice(2, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
}
filteredItems.splice(4, 0, {
labelKey: "ui.admin.nav.user_projection",
labelFallback: "User Projection",
to: "/system/projections/users",
icon: Database,
});
filteredItems.splice(5, 0, {
labelKey: "ui.admin.nav.data_integrity",
labelFallback: "Data Integrity",
to: "/system/data-integrity",
@@ -218,13 +242,14 @@ function AppLayout() {
icon: Building2,
});
}
filteredItems.splice(filteredItems.findIndex(i => i.to === "/users") + 1, 0, {
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
if (showWorksmobile) {
filteredItems.splice(2, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
}
}
return filteredItems;

View File

@@ -6,6 +6,9 @@ import {
fetchDataIntegrityReport,
fetchMe,
fetchOrphanUserLoginIDs,
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import DataIntegrityPage from "./DataIntegrityPage";
@@ -60,6 +63,24 @@ vi.mock("../../lib/adminApi", () => ({
],
total: 1,
})),
fetchUserProjectionStatus: vi.fn(async () => ({
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
})),
reconcileUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:01:00Z",
})),
resetUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:02:00Z",
})),
deleteOrphanUserLoginIDs: vi.fn(async () => ({
deletedCount: 1,
deleted: [
@@ -95,6 +116,7 @@ describe("DataIntegrityPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
});
@@ -102,6 +124,12 @@ describe("DataIntegrityPage", () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "정합성 검사" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "사용자 동기화" }),
).toBeInTheDocument();
expect(
await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
@@ -113,6 +141,28 @@ describe("DataIntegrityPage", () => {
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("renders user projection sync inside data integrity", async () => {
renderPage();
fireEvent.click(await screen.findByRole("tab", { name: "사용자 동기화" }));
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
await waitFor(() => {
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
await waitFor(() => {
expect(resetUserProjection).toHaveBeenCalledTimes(1);
});
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage();

View File

@@ -19,6 +19,7 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
import { UserProjectionContent } from "../projections/UserProjectionPage";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
@@ -187,6 +188,14 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") {
}
}
function pageTabClassName(active: boolean) {
return `relative px-6 py-3 text-sm font-medium transition-colors ${
active
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`;
}
function OrphanLoginIDTable({
items,
selectedIds,
@@ -284,6 +293,9 @@ function OrphanLoginIDTable({
function DataIntegrityContent() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
"integrity",
);
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
@@ -360,210 +372,243 @@ function DataIntegrityContent() {
</p>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
</header>
<div className="space-y-4 pb-6">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
{activeTab === "integrity" ? (
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
) : null}
</header>
<div
className="flex border-b border-border"
role="tablist"
aria-label="데이터 정합성 탭"
>
<button
type="button"
role="tab"
aria-selected={activeTab === "integrity"}
className={pageTabClassName(activeTab === "integrity")}
onClick={() => setActiveTab("integrity")}
>
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "projection"}
className={pageTabClassName(activeTab === "projection")}
onClick={() => setActiveTab("projection")}
>
{t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
</button>
</div>
{activeTab === "integrity" ? (
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
</div>
) : (
<div className="animate-in fade-in duration-500">
<UserProjectionContent embedded />
</div>
)}
</main>
);
}

View File

@@ -55,7 +55,11 @@ function ProjectionStatusBadge({
);
}
function UserProjectionContent() {
export function UserProjectionContent({
embedded = false,
}: {
embedded?: boolean;
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["user-projection-status"],
@@ -94,50 +98,55 @@ function UserProjectionContent() {
const actionResult = reconcileMutation.data ?? resetMutation.data;
const actionError = reconcileMutation.error ?? resetMutation.error;
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Users size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t(
"ui.admin.user_projection.title",
"User Projection Management",
)}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.subtitle",
"Review and sync the Kratos user read model.",
)}
</p>
</div>
const header = (
<header
className={
embedded
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
}
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Users size={20} />
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
</Button>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.user_projection.title", "User Projection Management")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.subtitle",
"Review and sync the Kratos user read model.",
)}
</p>
</div>
</header>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
</Button>
</div>
</header>
);
const body = (
<>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
@@ -243,6 +252,22 @@ function UserProjectionContent() {
</div>
) : null}
</section>
</>
);
if (embedded) {
return (
<div className="space-y-4 pb-6">
{header}
{body}
</div>
);
}
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
{header}
{body}
</main>
);
}

View File

@@ -33,6 +33,8 @@ type ParentTenantSelectorProps = {
orgChartPickerLabel?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean;
controlTestId?: string;
};
export function ParentTenantSelector({
@@ -49,6 +51,8 @@ export function ParentTenantSelector({
orgChartPickerLabel,
localPickerLabel,
localTenantFilter,
compact = false,
controlTestId,
}: ParentTenantSelectorProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [localPickerOpen, setLocalPickerOpen] = useState(false);
@@ -81,19 +85,37 @@ export function ParentTenantSelector({
}, [excludeTenantId, onChange, pickerOpen]);
return (
<div className="space-y-2">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
<div className={compact ? "space-y-1" : "space-y-2"}>
<div
className={
compact
? "flex min-h-5 flex-wrap items-center justify-between gap-2"
: "flex min-h-8 flex-wrap items-center justify-between gap-2"
}
>
<Label className="text-sm font-semibold">{label}</Label>
{labelAction}
</div>
<input id={id} name={id} type="hidden" value={value} readOnly />
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
<div
data-testid={controlTestId}
className={
compact
? "flex h-10 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2"
: "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2"
}
>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Button
type="button"
variant="outline"
size="sm"
className={compact ? "h-8 shrink-0 px-2" : undefined}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
selectedTenant?.name ??
(compact ? undefined : selectedTenant?.name) ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
</DialogTrigger>
@@ -185,14 +207,23 @@ export function ParentTenantSelector({
)}
{selectedTenant ? (
<>
<span className="text-xs text-muted-foreground">
{selectedTenant.slug} · {selectedTenant.type}
<span
className={
compact
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
: "text-xs text-muted-foreground"
}
title={`${selectedTenant.name} · ${selectedTenant.slug} · ${selectedTenant.type}`}
>
{compact
? `${selectedTenant.name} · ${selectedTenant.slug}`
: `${selectedTenant.slug} · ${selectedTenant.type}`}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
onClick={() => onChange("")}
aria-label={noneLabel}
>
@@ -200,7 +231,15 @@ export function ParentTenantSelector({
</Button>
</>
) : (
<span className="text-xs text-muted-foreground">{noneLabel}</span>
<span
className={
compact
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
: "text-xs text-muted-foreground"
}
>
{noneLabel}
</span>
)}
{contextLabel && (
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">

View File

@@ -4,6 +4,7 @@ import { Building2, Sparkles } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { Checkbox } from "../../../components/ui/checkbox";
import {
Card,
CardContent,
@@ -46,6 +47,7 @@ function TenantCreatePage() {
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public");
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState<string[]>([]);
@@ -109,7 +111,11 @@ function TenantCreatePage() {
status,
domains,
config: canConfigureHanmacOrg
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
? mergeTenantOrgConfig(undefined, {
orgUnitType,
visibility,
worksmobileExcluded,
})
: undefined,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}),
@@ -284,6 +290,27 @@ function TenantCreatePage() {
))}
</select>
</div>
<div
data-testid="tenant-worksmobile-excluded-slot"
className="flex min-h-9 items-center gap-2 rounded-md border border-input px-3 py-2"
>
<Checkbox
id="worksmobileExcluded"
checked={worksmobileExcluded}
onCheckedChange={(checked) =>
setWorksmobileExcluded(checked === true)
}
/>
<Label
htmlFor="worksmobileExcluded"
className="cursor-pointer text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.worksmobile_excluded",
"WORKS 연동 제외",
)}
</Label>
</div>
</>
)}
</div>

View File

@@ -1,7 +0,0 @@
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
describe("TenantDetailPage Worksmobile entry visibility", () => {
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
expect(
canShowWorksmobileEntry({
id: "hanmac-family-id",
slug: "hanmac-family",
parentId: undefined,
}),
).toBe(true);
expect(
canShowWorksmobileEntry({
id: "hanmac-child-id",
slug: "hanmac-family",
parentId: "root-id",
}),
).toBe(false);
expect(
canShowWorksmobileEntry({
id: "other-id",
slug: "other",
parentId: undefined,
}),
).toBe(false);
});
});

View File

@@ -5,7 +5,6 @@ import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
@@ -125,18 +124,6 @@ function TenantDetailPage() {
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
{showWorksmobileEntry && (
<Link
to={`/tenants/${tenantId}/worksmobile`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isWorksmobileTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
</Link>
)}
</div>
{/* Outlet for nested routes */}

View File

@@ -29,7 +29,6 @@ function renderTenantDetailPage() {
<Routes>
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
<Route index element={<div>profile</div>} />
<Route path="worksmobile" element={<div>worksmobile</div>} />
</Route>
</Routes>
</MemoryRouter>
@@ -42,16 +41,11 @@ describe("TenantDetailPage Worksmobile navigation", () => {
vi.clearAllMocks();
});
it("opens Worksmobile management in the current admin route", async () => {
it("does not render Worksmobile as a tenant detail tab", async () => {
renderTenantDetailPage();
const link = await screen.findByRole("link", { name: /Worksmobile/i });
await screen.findByText("프로필");
expect(link).toHaveAttribute(
"href",
"/tenants/hanmac-family-id/worksmobile",
);
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
expect(screen.queryByRole("link", { name: /Worksmobile/i })).toBeNull();
});
});

View File

@@ -116,7 +116,7 @@ import {
} from "./tenantListView";
const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n";
const tenantPageSize = 500;
const _tenantVirtualizationThreshold = 250;
const _tenantEstimatedRowHeight = 73;

View File

@@ -30,8 +30,8 @@ import {
type ServerDomainConflict,
} from "../utils/domainTags";
import {
getOrgUnitTypeOptionsForTenantType,
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
@@ -70,6 +70,7 @@ export function TenantProfilePage() {
const [orgUnitType, setOrgUnitType] = useState("");
const [tenantVisibility, setTenantVisibility] =
useState<TenantVisibility>("public");
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
useEffect(() => {
if (tenantQuery.data) {
@@ -84,6 +85,7 @@ export function TenantProfilePage() {
setParentId(tenantQuery.data.parentId ?? "");
setOrgUnitType(orgConfig.orgUnitType);
setTenantVisibility(orgConfig.visibility);
setWorksmobileExcluded(orgConfig.worksmobileExcluded);
}
}, [tenantQuery.data]);
@@ -101,6 +103,7 @@ export function TenantProfilePage() {
orgConfigCandidate,
])
: false;
const orgUnitTypeOptions = getOrgUnitTypeOptionsForTenantType(type);
const updateMutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) => {
@@ -109,6 +112,7 @@ export function TenantProfilePage() {
? mergeTenantOrgConfig(baseConfig, {
orgUnitType,
visibility: tenantVisibility,
worksmobileExcluded,
})
: removeTenantOrgConfig(baseConfig);
@@ -226,78 +230,46 @@ export function TenantProfilePage() {
return (
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
<Card className="mt-4 bg-[var(--color-panel)]">
<CardHeader className="px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-lg">
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 px-5 pb-4">
{loadError && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{loadError}
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div
data-testid="tenant-parent-org-config-layout"
className="grid gap-4 md:grid-cols-4"
data-testid="tenant-profile-primary-row"
className="grid gap-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.8fr)_minmax(320px,1.4fr)]"
>
<div
data-testid="tenant-parent-picker-slot"
className={canEditOrgConfig ? "md:col-span-2" : "md:col-span-4"}
>
<div data-testid="tenant-name-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector
id="parentId"
label={t(
@@ -308,18 +280,61 @@ export function TenantProfilePage() {
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음 (최상위)")}
helpText={t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
excludeTenantId={tenantId}
compact
controlTestId="tenant-parent-picker-control"
/>
</div>
</div>
<div
data-testid="tenant-profile-config-row"
className="grid gap-3 lg:grid-cols-[minmax(190px,1fr)_minmax(150px,0.8fr)_minmax(150px,0.8fr)_minmax(190px,0.9fr)]"
>
<div data-testid="tenant-type-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
data-testid="tenant-type-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
{canEditOrgConfig && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-2"
className="space-y-1"
>
<Label className="text-sm font-semibold">
{t(
@@ -328,19 +343,20 @@ export function TenantProfilePage() {
)}
</Label>
<select
data-testid="tenant-org-unit-type-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
{orgUnitTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div data-testid="tenant-visibility-slot" className="space-y-2">
<div data-testid="tenant-visibility-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
@@ -360,68 +376,92 @@ export function TenantProfilePage() {
))}
</select>
</div>
<div
data-testid="tenant-worksmobile-excluded-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.worksmobile_sync",
"WORKS 연동",
)}
</Label>
<select
id="worksmobileExcluded"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={worksmobileExcluded ? "excluded" : "enabled"}
onChange={(event) =>
setWorksmobileExcluded(event.target.value === "excluded")
}
>
<option value="enabled">
{t(
"ui.admin.tenants.profile.worksmobile_enabled",
"연동",
)}
</option>
<option value="excluded">
{t(
"ui.admin.tenants.profile.worksmobile_excluded",
"제외",
)}
</option>
</select>
</div>
</>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
currentTenantId={tenantId}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
{t(
"ui.admin.tenants.profile.allowed_domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
<div className="grid gap-3 lg:grid-cols-[minmax(260px,0.9fr)_minmax(360px,1.4fr)_auto]">
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
currentTenantId={tenantId}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</div>
{errorMsg && (
@@ -432,7 +472,7 @@ export function TenantProfilePage() {
</CardContent>
</Card>
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<Button
variant="outline"
onClick={handleDelete}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
canAccessWorksmobile,
HANMAC_FAMILY_TENANT_ID,
} from "./worksmobileAccess";
describe("worksmobile access", () => {
it("allows super admins", () => {
expect(canAccessWorksmobile({ role: "super_admin" })).toBe(true);
});
it("allows hanmac-family tenant managers", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ id: HANMAC_FAMILY_TENANT_ID }],
}),
).toBe(true);
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ slug: "hanmac-family" }],
}),
).toBe(true);
});
it("rejects admins that do not manage hanmac-family", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ slug: "other-company" }],
}),
).toBe(false);
expect(
canAccessWorksmobile({
role: "user",
tenantId: HANMAC_FAMILY_TENANT_ID,
tenantSlug: "hanmac-family",
}),
).toBe(false);
expect(canAccessWorksmobile({ role: "user" })).toBe(false);
});
it("rejects admins that only manage Worksmobile-excluded hanmac-family tenants", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [
{
slug: "hanmac-family",
config: { worksmobileExcluded: true },
},
],
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,60 @@
import { isSuperAdminRole } from "../../../lib/roles";
export const HANMAC_FAMILY_TENANT_ID = "038326b6-954a-48a7-a85f-efd83f62b82a";
export const HANMAC_FAMILY_TENANT_SLUG = "hanmac-family";
export type WorksmobileAccessProfile = {
role?: string;
tenantId?: string;
tenantSlug?: string;
tenant?: {
id?: string;
slug?: string;
config?: Record<string, unknown>;
};
manageableTenants?: Array<{
id?: string;
slug?: string;
config?: Record<string, unknown>;
}>;
};
export function isWorksmobileExcludedConfig(
config?: Record<string, unknown>,
) {
const rawValue = config?.worksmobileExcluded;
return (
rawValue === true || String(rawValue ?? "").trim().toLowerCase() === "true"
);
}
function isProfileTenantWorksmobileExcluded(
profile?: WorksmobileAccessProfile | null,
) {
if (isWorksmobileExcludedConfig(profile?.tenant?.config)) {
return true;
}
return (profile?.manageableTenants ?? []).some((tenant) => {
const isCurrentTenant =
(profile?.tenantId && tenant.id === profile.tenantId) ||
(profile?.tenantSlug && tenant.slug === profile.tenantSlug);
return isCurrentTenant && isWorksmobileExcludedConfig(tenant.config);
});
}
export function canAccessWorksmobile(
profile?: WorksmobileAccessProfile | null,
) {
if (isSuperAdminRole(profile?.role)) {
return true;
}
if (isProfileTenantWorksmobileExcluded(profile)) {
return false;
}
return (profile?.manageableTenants ?? []).some(
(tenant) =>
!isWorksmobileExcludedConfig(tenant.config) &&
(tenant.id === HANMAC_FAMILY_TENANT_ID ||
tenant.slug === HANMAC_FAMILY_TENANT_SLUG),
);
}

View File

@@ -4,6 +4,7 @@ import {
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "./orgConfig";
@@ -49,17 +50,69 @@ describe("tenant org config", () => {
it("reads and writes tenant visibility and org unit type", () => {
expect(
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
).toEqual({ orgUnitType: "팀", visibility: "private" });
).toEqual({
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: false,
});
expect(
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
).toEqual({ orgUnitType: "센터", visibility: "internal" });
).toEqual({
orgUnitType: "센터",
visibility: "internal",
worksmobileExcluded: false,
});
expect(
mergeTenantOrgConfig(
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
{ orgUnitType: "", visibility: "internal" },
{
orgUnitType: "",
visibility: "internal",
worksmobileExcluded: false,
},
),
).toEqual({ userSchema: [], visibility: "internal" });
).toEqual({
userSchema: [],
visibility: "internal",
worksmobileExcluded: false,
});
});
it("reads, writes, and removes the Worksmobile exclusion flag", () => {
expect(
readTenantOrgConfig({ worksmobileExcluded: true }),
).toMatchObject({
worksmobileExcluded: true,
});
expect(
readTenantOrgConfig({ worksmobileExcluded: "true" }),
).toMatchObject({
worksmobileExcluded: true,
});
expect(
mergeTenantOrgConfig(
{ userSchema: [], worksmobileExcluded: false },
{
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
},
),
).toEqual({
userSchema: [],
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
});
expect(
removeTenantOrgConfig({
userSchema: [],
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
}),
).toEqual({ userSchema: [] });
});
it("includes task-force and executive-direct org unit types", () => {

View File

@@ -14,6 +14,13 @@ export const ORG_UNIT_TYPE_OPTIONS = [
"임원직속",
] as const;
export const USER_GROUP_ORG_UNIT_TYPE_OPTIONS = [
"팀",
"TF",
"TF팀",
"셀",
] as const;
export const TENANT_VISIBILITY_OPTIONS = [
{ label: "공개", value: "public" },
{ label: "내부", value: "internal" },
@@ -26,6 +33,7 @@ export type TenantVisibility =
export type TenantOrgConfig = {
orgUnitType: string;
visibility: TenantVisibility;
worksmobileExcluded: boolean;
};
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
@@ -55,17 +63,29 @@ export function shouldAllowHanmacOrgConfig(
return false;
}
export function getOrgUnitTypeOptionsForTenantType(type: string) {
return type === "USER_GROUP"
? USER_GROUP_ORG_UNIT_TYPE_OPTIONS
: ORG_UNIT_TYPE_OPTIONS;
}
export function readTenantOrgConfig(
config: Record<string, unknown> | undefined,
): TenantOrgConfig {
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
const rawOrgUnitType = String(config?.orgUnitType ?? "");
const rawWorksmobileExcluded = config?.worksmobileExcluded;
return {
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
? (rawVisibility as TenantVisibility)
: "public",
worksmobileExcluded:
rawWorksmobileExcluded === true ||
String(rawWorksmobileExcluded ?? "")
.trim()
.toLowerCase() === "true",
};
}
@@ -76,6 +96,7 @@ export function mergeTenantOrgConfig(
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
const merged = { ...rest };
merged.visibility = next.visibility;
merged.worksmobileExcluded = next.worksmobileExcluded;
if (next.orgUnitType) {
merged.orgUnitType = next.orgUnitType;
@@ -90,6 +111,7 @@ export function removeTenantOrgConfig(
const {
orgUnitType: _orgUnitType,
visibility: _visibility,
worksmobileExcluded: _worksmobileExcluded,
...rest
} = config ?? {};
return rest;

View File

@@ -66,7 +66,7 @@ describe("tenantCsvImport", () => {
it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터\n",
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터,no\n",
);
expect(rows).toEqual([
@@ -82,6 +82,7 @@ describe("tenantCsvImport", () => {
emailDomain: "hanmac-tech.example.com",
visibility: "internal",
orgUnitType: "센터",
worksmobileSync: "no",
},
]);
});
@@ -111,7 +112,7 @@ describe("tenantCsvImport", () => {
it("serializes selected matches by filling tenant_id before upload", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀\n",
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no\n",
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
@@ -119,10 +120,10 @@ describe("tenantCsvImport", () => {
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
);
expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀",
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no",
);
});
@@ -253,10 +254,10 @@ ${exportedTenantId},Tenant With UUID,COMPANY,,,tenant-with-uuid,Memo,tenant-with
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,,,,yes",
);
});

View File

@@ -12,6 +12,7 @@ export type TenantCSVRow = {
emailDomain: string;
visibility: string;
orgUnitType: string;
worksmobileSync: string;
};
export type TenantCSVParseOptions = {
@@ -80,6 +81,7 @@ const importHeaders = [
"email_domain",
"visibility",
"org_unit_type",
"worksmobile_sync",
];
const headerAliases: Record<string, TenantCSVSourceKey> = {
@@ -116,6 +118,11 @@ const headerAliases: Record<string, TenantCSVSourceKey> = {
organization_type: "orgUnitType",
orgtype: "orgUnitType",
org_type: "orgUnitType",
worksmobile: "worksmobileSync",
worksmobilesync: "worksmobileSync",
worksmobile_sync: "worksmobileSync",
works_sync: "worksmobileSync",
works: "worksmobileSync",
};
export function parseTenantCSV(
@@ -175,6 +182,7 @@ export function parseTenantCSV(
emailDomain: value("emailDomain"),
visibility: value("visibility"),
orgUnitType: value("orgUnitType"),
worksmobileSync: normalizeWorksmobileSync(value("worksmobileSync")),
};
});
}
@@ -305,6 +313,7 @@ export function serializeTenantImportCSV(
preview.row.emailDomain,
preview.row.visibility,
preview.row.orgUnitType,
preview.row.worksmobileSync || "yes",
]);
}
return `${lines.map(formatCSVRecord).join("\n")}\n`;
@@ -528,6 +537,30 @@ function normalizeHeader(value: string) {
return value.trim().toLowerCase().replaceAll(" ", "_");
}
function normalizeWorksmobileSync(value: string) {
const normalized = value.trim().toLowerCase();
if (
[
"no",
"n",
"false",
"0",
"off",
"none",
"excluded",
"exclude",
"not_sync",
"not-synced",
"미연동",
"연동안함",
"제외",
].includes(normalized)
) {
return "no";
}
return "yes";
}
function slugFromMailingList(value: string) {
if (!value) return "";
return normalizeTenantSlug(value.split("@")[0] ?? value);

View File

@@ -52,6 +52,7 @@ import { isSuperAdminRole } from "../../lib/roles";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
@@ -896,63 +897,73 @@ function UserCreatePage() {
data-testid={`appointment-tenant-owner-line-${index}`}
>
<Label> </Label>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
disabled={isResolvingTenant}
data-testid={`appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4" />
{appointment.tenantName || "테넌트 선택"}
</Button>
{appointment.tenantSlug && (
<span className="text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
<div
className="flex items-center justify-between gap-3"
data-testid={`appointment-tenant-owner-controls-${index}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
type="button"
variant="outline"
className="min-w-0 max-w-full"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
aria-label={t(
disabled={isResolvingTenant}
data-testid={`appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{appointment.tenantName || "테넌트 선택"}
</span>
</Button>
{appointment.tenantSlug && (
<span className="truncate text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
</label>
<label className="flex items-center gap-2 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</label>
</div>
</div>
</div>
@@ -964,15 +975,26 @@ function UserCreatePage() {
<Label htmlFor={`appointment-grade-${index}`}>
</Label>
<Input
<select
id={`appointment-grade-${index}`}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value,
grade: event.target.value || "",
})
}
/>
>
<option value=""></option>
{getTenantGradeOptions(
appointment,
tenants,
).map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor={`appointment-job-title-${index}`}>

View File

@@ -79,6 +79,7 @@ import { generateSecurePassword } from "../../lib/utils";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
isHanmacFamilyTenant,
isHanmacFamilyUser,
type OrgChartTenantSelection,
@@ -1444,67 +1445,78 @@ function UserDetailPage() {
"소속 테넌트",
)}
</Label>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
disabled={isResolvingTenant}
>
<Building2 className="mr-2 h-4 w-4" />
{appointment.tenantName ||
t(
"ui.admin.users.detail.form.pick_tenant",
"테넌트 선택",
)}
</Button>
{appointment.tenantSlug && (
<span className="text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
<div
className="flex items-center justify-between gap-3"
data-testid={`detail-appointment-tenant-owner-controls-${index}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
type="button"
variant="outline"
className="min-w-0 max-w-full"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
disabled={appointment.isPrimary === true}
aria-label={t(
disabled={isResolvingTenant}
data-testid={`detail-appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{appointment.tenantName ||
t(
"ui.admin.users.detail.form.pick_tenant",
"테넌트 선택",
)}
</span>
</Button>
{appointment.tenantSlug && (
<span className="truncate text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
})
}
disabled={appointment.isPrimary === true}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
</label>
<label className="flex items-center gap-2 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</label>
</div>
</div>
</div>
@@ -1521,15 +1533,26 @@ function UserDetailPage() {
"직급",
)}
</Label>
<Input
<select
id={`detail-appointment-grade-${index}`}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value,
grade: event.target.value || "",
})
}
/>
>
<option value=""></option>
{getTenantGradeOptions(
appointment,
tenants,
).map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label

View File

@@ -73,6 +73,7 @@ function buildUserTenantPreviewRows(
emailDomain: user.tenantImport?.emailDomain ?? "",
visibility: "public",
orgUnitType: "node",
worksmobileSync: "yes",
});
});

View File

@@ -4,6 +4,7 @@ import {
buildAuthenticatedOrgChartUrl,
buildOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
@@ -114,6 +115,22 @@ describe("orgChartPicker", () => {
type: "COMPANY",
parentId: undefined,
},
{
id: "internal-id",
slug: "internal",
name: "Internal",
type: "COMPANY",
parentId: undefined,
config: { visibility: "internal" },
},
{
id: "private-id",
slug: "private",
name: "Private",
type: "COMPANY",
parentId: undefined,
visibility: "private",
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
@@ -249,4 +266,54 @@ describe("orgChartPicker", () => {
),
).toBe(false);
});
it("returns GPDTDC rank options for GPDTDC subtree and general Hanmac ranks otherwise", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "gpdtdc-id",
slug: "gpdtdc",
name: "총괄기획&기술개발센터",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "gpdtdc-team-id",
slug: "gpdtdc-team",
name: "연구팀",
type: "USER_GROUP",
parentId: "gpdtdc-id",
},
{
id: "hanmac-id",
slug: "hanmac",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
];
expect(
getTenantGradeOptions({ tenantId: "gpdtdc-team-id" }, tenants),
).toEqual(["연구원", "선임", "책임", "수석", "부사장", "사장"]);
expect(getTenantGradeOptions({ tenantSlug: "hanmac" }, tenants)).toEqual([
"사원",
"대리",
"과장",
"차장",
"부장",
"이사",
"상무",
"전무",
"부사장",
"사장",
"회장",
]);
});
});

View File

@@ -12,6 +12,8 @@ export type TenantFilterTarget = {
parentId?: string | null;
name?: string;
tenantName?: string;
visibility?: string;
config?: Record<string, unknown>;
};
export type HanmacFamilyUserTarget = {
@@ -43,6 +45,29 @@ type OrgChartLoginOptions = {
returnTo?: string;
};
export const GPDTDC_GRADE_OPTIONS = [
"연구원",
"선임",
"책임",
"수석",
"부사장",
"사장",
] as const;
export const HANMAC_FAMILY_GRADE_OPTIONS = [
"사원",
"대리",
"과장",
"차장",
"부장",
"이사",
"상무",
"전무",
"부사장",
"사장",
"회장",
] as const;
function isSystemTenant(tenant: TenantFilterTarget) {
const slug = tenant.slug?.trim().toLowerCase();
const type = tenant.type?.trim().toUpperCase();
@@ -56,6 +81,73 @@ function isSystemTenant(tenant: TenantFilterTarget) {
);
}
function resolveTenantTarget<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
if (!target) return undefined;
const tenantID = target.id ?? target.tenantId ?? "";
const tenantSlug = target.slug ?? target.tenantSlug ?? "";
return (
tenants.find((tenant) => tenantID && tenant.id === tenantID) ??
tenants.find(
(tenant) =>
tenantSlug &&
tenant.slug?.trim().toLowerCase() === tenantSlug.trim().toLowerCase(),
) ??
target
);
}
function isGPDTDCTenant<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
const tenant = resolveTenantTarget(target, tenants);
if (!tenant) return false;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
let current: TenantFilterTarget | undefined = tenant;
const visited = new Set<string>();
while (current) {
const slug = current.slug?.trim().toLowerCase();
if (slug === "gpdtdc") {
return true;
}
const parentId = current.parentId ?? "";
if (!parentId || visited.has(parentId)) {
return false;
}
visited.add(parentId);
current = tenantById.get(parentId);
}
return false;
}
export function getTenantGradeOptions<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
return isGPDTDCTenant(target, tenants)
? [...GPDTDC_GRADE_OPTIONS]
: [...HANMAC_FAMILY_GRADE_OPTIONS];
}
function isPublicRepresentativeTenant(tenant: TenantFilterTarget) {
const visibility = String(
tenant.visibility ?? tenant.config?.visibility ?? "public",
)
.trim()
.toLowerCase();
return visibility !== "internal" && visibility !== "private";
}
function isInTenantSubtree<T extends TenantFilterTarget>(
tenant: T,
rootTenantId: string,
@@ -187,6 +279,7 @@ export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
isPublicRepresentativeTenant(tenant) &&
!isInTenantSubtree(tenant, rootTenantId, tenantById),
);
}

View File

@@ -818,6 +818,10 @@ export type WorksmobileCredentialBatchFailure = {
updatedAt?: string;
};
export type WorksmobilePendingJobDeleteResult = {
deletedCount: number;
};
export type WorksmobileComparisonItem = {
resourceType: string;
baronId?: string;
@@ -978,6 +982,13 @@ export async function deleteWorksmobileCredentialBatchPasswords(
return data;
}
export async function deleteWorksmobilePendingJobs(tenantId: string) {
const { data } = await apiClient.delete<WorksmobilePendingJobDeleteResult>(
`/v1/admin/tenants/${tenantId}/worksmobile/jobs/pending`,
);
return data;
}
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,

View File

@@ -1068,7 +1068,7 @@ test.describe("Tenants Management", () => {
await expect(page.getByTestId("tenant-detail-copy-uuid")).toBeVisible();
});
test("should place hanmac org config beside parent tenant picker", async ({
test("should place tenant profile core settings in dense rows", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 800 });
@@ -1119,29 +1119,102 @@ test.describe("Tenants Management", () => {
await page.goto("/tenants/team-1");
const layout = page.getByTestId("tenant-parent-org-config-layout");
await expect(layout).toBeVisible({ timeout: 20000 });
await expect(layout).toContainText("상위 테넌트");
await expect(layout).toContainText("조직 세부타입");
await expect(layout).toContainText("공개 범위");
const topLayout = page.getByTestId("tenant-profile-primary-row");
const configLayout = page.getByTestId("tenant-profile-config-row");
await expect(topLayout).toBeVisible({ timeout: 20000 });
await expect(configLayout).toBeVisible();
await expect(topLayout).toContainText("테넌트 이름");
await expect(topLayout).toContainText("슬러그");
await expect(topLayout).toContainText("상위 테넌트");
await expect(configLayout).toContainText("테넌트 유형");
await expect(configLayout).toContainText("조직 세부타입");
await expect(configLayout).toContainText("공개 범위");
await expect(configLayout).toContainText("WORKS 연동");
const orgUnitTypeSelect = page.getByTestId("tenant-org-unit-type-select");
await expect(orgUnitTypeSelect).toBeVisible();
await expect(orgUnitTypeSelect.locator("option")).toHaveText([
"없음",
"팀",
"TF",
"TF팀",
"셀",
]);
const columns = await layout.evaluate(
const topColumns = await topLayout.evaluate(
(element) => window.getComputedStyle(element).gridTemplateColumns,
);
expect(columns.split(" ").length).toBe(4);
const configColumns = await configLayout.evaluate(
(element) => window.getComputedStyle(element).gridTemplateColumns,
);
expect(topColumns.split(" ").length).toBe(3);
expect(configColumns.split(" ").length).toBe(4);
const parentWidth = await page
const nameTop = await page
.getByTestId("tenant-name-slot")
.evaluate((element) => element.getBoundingClientRect().top);
const slugTop = await page
.getByTestId("tenant-slug-slot")
.evaluate((element) => element.getBoundingClientRect().top);
const parentTop = await page
.getByTestId("tenant-parent-picker-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const orgUnitWidth = await page
.evaluate((element) => element.getBoundingClientRect().top);
const nameInputHeight = await page
.getByTestId("tenant-name-slot")
.locator("input")
.evaluate((element) => element.getBoundingClientRect().height);
const slugInputHeight = await page
.getByTestId("tenant-slug-slot")
.locator("input")
.evaluate((element) => element.getBoundingClientRect().height);
const parentControlHeight = await page
.getByTestId("tenant-parent-picker-control")
.evaluate((element) => element.getBoundingClientRect().height);
const typeTop = await page
.getByTestId("tenant-type-slot")
.evaluate((element) => element.getBoundingClientRect().top);
const orgUnitTop = await page
.getByTestId("tenant-org-unit-type-slot")
.evaluate((element) => element.getBoundingClientRect().width);
const visibilityWidth = await page
.evaluate((element) => element.getBoundingClientRect().top);
const visibilityTop = await page
.getByTestId("tenant-visibility-slot")
.evaluate((element) => element.getBoundingClientRect().width);
.evaluate((element) => element.getBoundingClientRect().top);
const worksExcludedTop = await page
.getByTestId("tenant-worksmobile-excluded-slot")
.evaluate((element) => element.getBoundingClientRect().top);
expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7);
expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3);
expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8);
expect(Math.abs(nameTop - slugTop)).toBeLessThan(4);
expect(Math.abs(nameTop - parentTop)).toBeLessThan(4);
expect(Math.abs(nameInputHeight - slugInputHeight)).toBeLessThan(2);
expect(Math.abs(nameInputHeight - parentControlHeight)).toBeLessThan(4);
expect(Math.abs(typeTop - orgUnitTop)).toBeLessThan(4);
expect(Math.abs(typeTop - visibilityTop)).toBeLessThan(4);
expect(Math.abs(typeTop - worksExcludedTop)).toBeLessThan(4);
await page.getByTestId("tenant-type-select").selectOption("COMPANY");
await expect(orgUnitTypeSelect.locator("option")).toHaveText([
"없음",
"실",
"팀",
"TF",
"TF팀",
"센터",
"디비전",
"셀",
"본부",
"지역본부",
"부",
"임원직속",
]);
const overflow = await page.evaluate(() => ({
horizontal:
document.documentElement.scrollWidth >
document.documentElement.clientWidth,
vertical:
document.documentElement.scrollHeight >
document.documentElement.clientHeight,
}));
expect(overflow.horizontal).toBe(false);
expect(overflow.vertical).toBe(false);
});
});

View File

@@ -729,7 +729,6 @@ test.describe("User Management", () => {
await expect(page.getByTestId("appointment-position-line-0")).toBeVisible();
await page.getByRole("switch", { name: /대표 조직/i }).click();
await page.getByLabel(/^직무$/i).fill("플랫폼 운영");
await page.getByLabel(/^직급$/i).fill("책임");
await page.getByLabel(/^직책$/i).fill("팀장");
await page.locator('input[name="name"]').fill("Family User");
@@ -829,6 +828,7 @@ test.describe("User Management", () => {
test("should show Hanmac family appointments layout on user detail", async ({
page,
}) => {
await page.setViewportSize({ width: 520, height: 900 });
await page.route(/\/admin\/users\/u-1$/, async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
@@ -847,7 +847,7 @@ test.describe("User Management", () => {
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
tenantSlug: "tech-planning",
tenantName: "기술기획",
tenantName: "기술기획 장기 운영 전략 조직",
isPrimary: true,
isOwner: true,
grade: "책임",
@@ -872,6 +872,32 @@ test.describe("User Management", () => {
await expect(
page.getByTestId("detail-appointment-tenant-owner-line-0"),
).toContainText(/기술기획|대표 조직|조직장/);
await expect(
page.getByTestId("detail-appointment-tenant-owner-controls-0"),
).toHaveCSS("flex-wrap", "nowrap");
const tenantPickerBox = await page
.getByTestId("detail-appointment-tenant-picker-0")
.boundingBox();
const ownerSwitchBox = await page
.getByTestId("detail-appointment-row-0")
.getByRole("switch", { name: /대표 조직/i })
.boundingBox();
const managerSwitchBox = await page
.getByTestId("detail-appointment-row-0")
.getByRole("switch", { name: /조직장/i })
.boundingBox();
if (!tenantPickerBox || !ownerSwitchBox || !managerSwitchBox) {
throw new Error("Appointment tenant owner controls are not visible.");
}
const centerYs = [
tenantPickerBox.y + tenantPickerBox.height / 2,
ownerSwitchBox.y + ownerSwitchBox.height / 2,
managerSwitchBox.y + managerSwitchBox.height / 2,
];
expect(Math.max(...centerYs) - Math.min(...centerYs)).toBeLessThan(8);
await expect(
page.getByTestId("detail-appointment-row-0").getByRole("switch", {
name: /대표 조직/i,

View File

@@ -38,6 +38,11 @@ test.describe("Worksmobile tenant management", () => {
const url = new URL(route.request().url());
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
const isWorksmobileTenantPath = (suffix: string) =>
url.pathname.endsWith(`/admin/tenants/hanmac-family-id${suffix}`) ||
url.pathname.endsWith(
`/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a${suffix}`,
);
if (url.pathname.endsWith("/user/me")) {
return route.fulfill({
@@ -45,7 +50,14 @@ test.describe("Worksmobile tenant management", () => {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
manageableTenants: [
{
id: "038326b6-954a-48a7-a85f-efd83f62b82a",
name: "한맥 가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
},
],
},
headers,
});
@@ -68,10 +80,7 @@ test.describe("Worksmobile tenant management", () => {
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
method === "GET"
) {
if (isWorksmobileTenantPath("/worksmobile") && method === "GET") {
return route.fulfill({
json: {
tenant: {
@@ -95,18 +104,14 @@ test.describe("Worksmobile tenant management", () => {
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/credential-batches",
) &&
isWorksmobileTenantPath("/worksmobile/credential-batches") &&
method === "GET"
) {
return route.fulfill({ json: [], headers });
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
) &&
isWorksmobileTenantPath("/worksmobile/comparison") &&
method === "GET"
) {
const includeMatched =
@@ -210,9 +215,7 @@ test.describe("Worksmobile tenant management", () => {
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/users/user-missing/sync",
) &&
isWorksmobileTenantPath("/worksmobile/users/user-missing/sync") &&
method === "POST"
) {
syncRequests.push("user-missing");
@@ -225,24 +228,51 @@ test.describe("Worksmobile tenant management", () => {
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/hanmac-family-id");
await page.getByRole("link", { name: "Worksmobile" }).click();
await page.goto("/");
await expect(
page.getByRole("link", { name: "Worksmobile" }),
).toHaveAttribute("href", "/worksmobile");
await page.goto("/worksmobile");
await expect(page).toHaveURL(/\/tenants\/hanmac-family-id\/worksmobile$/);
await expect(page.getByText("Baron / Works 비교")).toBeVisible();
await expect(page).toHaveURL(/\/worksmobile$/);
await expect(page.getByRole("tab", { name: "이력" })).toBeVisible();
await expect(page.getByRole("tab", { name: "사용자" })).toBeVisible();
await expect(page.getByRole("tab", { name: "조직" })).toBeVisible();
await expect(page.getByText("비밀번호 파일 히스토리")).toBeVisible();
await expect(page.getByText("domainMappings")).not.toBeVisible();
await expect(page.getByText("SCIM token")).not.toBeVisible();
await page.getByRole("tab", { name: "사용자" }).click();
await expect(page.getByText("사용자 단건 동기화")).toBeVisible();
await expect(page.getByPlaceholder("Kratos user UUID")).toBeVisible();
const userSyncCard = page.getByTestId("worksmobile-users-single-sync");
const userComparisonTable = page.getByTestId(
"worksmobile-구성원-virtual-body",
);
await expect(userComparisonTable).toBeVisible();
await expect(userSyncCard).toBeVisible();
expect(
await page.evaluate(() => {
const table = document.querySelector(
'[data-testid="worksmobile-구성원-virtual-body"]',
);
const sync = document.querySelector(
'[data-testid="worksmobile-users-single-sync"]',
);
return Boolean(
table &&
sync &&
table.compareDocumentPosition(sync) &
Node.DOCUMENT_POSITION_FOLLOWING,
);
}),
).toBe(true);
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("숨김 SU")).not.toBeVisible();
await expect(page.getByText("숨김 CYHAN1")).not.toBeVisible();
await expect(page.getByText("su-@samaneng.com")).not.toBeVisible();
await expect(page.getByText("cyhan1@hanmaceng.co.kr")).not.toBeVisible();
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
await expect(page.getByText("WORKS 기술본부")).toBeVisible();
await expect(page.getByText("works-parent-tech")).toBeVisible();
await expect(page.getByText("WORKS 전용 조직")).not.toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
expect(comparisonRequests[0]).toBe(true);
@@ -321,6 +351,37 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await expect.poll(() => syncRequests).toEqual(["user-missing"]);
await page.getByRole("tab", { name: "조직" }).click();
await expect(page.getByText("조직 단건 동기화")).toBeVisible();
await expect(page.getByPlaceholder("orgUnit tenant UUID")).toBeVisible();
const groupSyncCard = page.getByTestId("worksmobile-groups-single-sync");
const groupComparisonTable = page.getByTestId(
"worksmobile-조직/그룹-virtual-body",
);
await expect(groupComparisonTable).toBeVisible();
await expect(groupSyncCard).toBeVisible();
expect(
await page.evaluate(() => {
const table = document.querySelector(
'[data-testid="worksmobile-조직/그룹-virtual-body"]',
);
const sync = document.querySelector(
'[data-testid="worksmobile-groups-single-sync"]',
);
return Boolean(
table &&
sync &&
table.compareDocumentPosition(sync) &
Node.DOCUMENT_POSITION_FOLLOWING,
);
}),
).toBe(true);
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
await expect(page.getByText("WORKS 기술본부")).toBeVisible();
await expect(page.getByText("works-parent-tech")).toBeVisible();
});
test("shows a toast when selected WORKS creation fails", async ({ page }) => {
@@ -352,7 +413,9 @@ test.describe("Worksmobile tenant management", () => {
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile",
) &&
method === "GET"
) {
return route.fulfill({
@@ -372,7 +435,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/credential-batches",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches",
) &&
method === "GET"
) {
@@ -381,7 +444,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison",
) &&
method === "GET"
) {
@@ -403,7 +466,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/users/user-fail/sync",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-fail/sync",
) &&
method === "POST"
) {
@@ -417,7 +480,8 @@ test.describe("Worksmobile tenant management", () => {
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/hanmac-family-id/worksmobile");
await page.goto("/worksmobile");
await page.getByRole("tab", { name: "사용자" }).click();
await page
.getByRole("row", { name: /실패 사용자/ })
.getByRole("checkbox")
@@ -465,7 +529,9 @@ test.describe("Worksmobile tenant management", () => {
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile",
) &&
method === "GET"
) {
return route.fulfill({
@@ -487,7 +553,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/credential-batches",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches",
) &&
method === "GET"
) {
@@ -496,7 +562,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison",
) &&
method === "GET"
) {
@@ -537,7 +603,8 @@ test.describe("Worksmobile tenant management", () => {
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/hanmac-family-id/worksmobile");
await page.goto("/worksmobile");
await page.getByRole("tab", { name: "사용자" }).click();
await expect(page.getByText("긴 WORKS 사용자")).toBeVisible();
const userColumnButton = page
@@ -608,7 +675,9 @@ test.describe("Worksmobile tenant management", () => {
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile",
) &&
method === "GET"
) {
return route.fulfill({
@@ -664,6 +733,24 @@ test.describe("Worksmobile tenant management", () => {
},
},
},
{
id: "job-pending",
resourceType: "ORGUNIT",
resourceId: "org-pending",
action: "UPSERT",
status: "pending",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "halla-site",
requestSummary: {
orgUnitName: "한라 현장",
email: "halla-site@hallasanup.com",
orgUnitExternalKey: "org-pending",
},
},
},
],
},
headers,
@@ -672,7 +759,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/credential-batches",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches",
) &&
method === "GET"
) {
@@ -696,7 +783,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison",
) &&
method === "GET"
) {
@@ -708,7 +795,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/initial-passwords.csv",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/initial-passwords.csv",
) &&
method === "GET"
) {
@@ -726,7 +813,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/backfill/dry-run",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/backfill/dry-run",
) &&
method === "POST"
) {
@@ -736,7 +823,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/orgunits/org-1/sync",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/orgunits/org-1/sync",
) &&
method === "POST"
) {
@@ -746,7 +833,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/users/user-1/sync",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-1/sync",
) &&
method === "POST"
) {
@@ -756,7 +843,7 @@ test.describe("Worksmobile tenant management", () => {
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/jobs/job-retry/retry",
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/jobs/job-retry/retry",
) &&
method === "POST"
) {
@@ -764,10 +851,20 @@ test.describe("Worksmobile tenant management", () => {
return route.fulfill({ json: { id: "job-retry-next" }, headers });
}
if (
url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/jobs/pending",
) &&
method === "DELETE"
) {
requests.push("delete-pending");
return route.fulfill({ json: { deletedCount: 1 }, headers });
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/hanmac-family-id/worksmobile");
await page.goto("/worksmobile");
await expect(page.getByText("Worksmobile 연동")).toBeVisible();
const download = page.waitForEvent("download");
@@ -785,27 +882,41 @@ test.describe("Worksmobile tenant management", () => {
await page.getByRole("button", { name: "Backfill Dry-run" }).click();
await expect.poll(() => requests).toContain("dry-run");
await page.getByRole("tab", { name: "조직" }).click();
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
await page.getByRole("button", { name: "조직 Sync" }).click();
await expect.poll(() => requests).toContain("org-sync");
await page.getByRole("tab", { name: "사용자" }).click();
await page.getByPlaceholder("Kratos user UUID").fill("user-1");
await page.getByRole("button", { name: "구성원 Sync" }).click();
await expect.poll(() => requests).toContain("user-sync");
await page.getByRole("tab", { name: "이력" }).click();
await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText(
"changed-user@example.com",
);
await expect(
page.getByRole("row", { name: /ORGUNIT:people-growth/ }),
).toContainText("people-growth@example.com");
await expect(page.getByText("externalKey:parent-org")).toBeVisible();
await expect(
page
.getByRole("row", { name: /ORGUNIT:people-growth/ })
.getByText("externalKey:parent-org")
.first(),
).toBeVisible();
const failedJobRow = page.getByRole("row", { name: /변경 사용자/ });
await failedJobRow.getByText("payload").click();
await expect(
failedJobRow.getByText('"loginEmail": "changed-user@example.com"'),
).toBeVisible();
await page
.getByRole("row", { name: /변경 사용자/ })
.getByRole("button")
.click();
await failedJobRow.getByRole("button").click();
await expect.poll(() => requests).toContain("retry");
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: /대기중 payload 삭제/ }).click();
await expect.poll(() => requests).toContain("delete-pending");
expect(requests).toContain("download-passwords");
});
});

View File

@@ -349,6 +349,7 @@ func main() {
}
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
userGroupService.SetWorksmobileSyncer(worksmobileService)
tenantService.SetKetoService(ketoService) // Keto 주입
hydraService := service.NewHydraAdminService()
@@ -755,6 +756,7 @@ func main() {
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs)
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
org := admin.Group("/tenants/:tenantId/organization")

View File

@@ -37,6 +37,7 @@ type InitialTenantConfig struct {
ParentSlug string
Description string
Domains []string
Config domain.JSONMap
}
func SeedTenants(db *gorm.DB) error {
@@ -149,6 +150,9 @@ func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
return err
}
tenant.Status = domain.TenantStatusActive
if len(config.Config) > 0 {
tenant.Config = config.Config
}
if err := db.Save(tenant).Error; err != nil {
return err
}
@@ -265,6 +269,11 @@ func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) {
return nil, fmt.Errorf("row %d: slug is required", i+2)
}
config, err := seedTenantCSVRecordConfig(row, header)
if err != nil {
return nil, fmt.Errorf("row %d: %w", i+2, err)
}
configs = append(configs, InitialTenantConfig{
TenantID: seedTenantCSVValue(row, header, "tenant_id"),
Name: name,
@@ -273,6 +282,7 @@ func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) {
Slug: slug,
Description: seedTenantCSVValue(row, header, "memo"),
Domains: splitSeedTenantCSVDomains(seedTenantCSVValue(row, header, "email_domain")),
Config: config,
})
}
@@ -298,6 +308,18 @@ func seedTenantCSVHeaderIndex(header []string) map[string]int {
"email_domain": "email_domain",
"domain": "email_domain",
"domains": "email_domain",
"visibility": "visibility",
"public_setting": "visibility",
"publicsetting": "visibility",
"org_unit_type": "org_unit_type",
"orgunittype": "org_unit_type",
"organization_type": "org_unit_type",
"organizationtype": "org_unit_type",
"worksmobile": "worksmobile_sync",
"worksmobilesync": "worksmobile_sync",
"worksmobile_sync": "worksmobile_sync",
"works_sync": "worksmobile_sync",
"works": "worksmobile_sync",
}
for i, column := range header {
key := strings.ToLower(strings.TrimSpace(column))
@@ -317,6 +339,67 @@ func seedTenantCSVValue(row []string, header map[string]int, key string) string
return strings.TrimSpace(row[idx])
}
func seedTenantCSVRecordConfig(row []string, header map[string]int) (domain.JSONMap, error) {
config := domain.JSONMap{}
visibility := strings.TrimSpace(seedTenantCSVValue(row, header, "visibility"))
if visibility != "" {
normalizedVisibility, err := normalizeSeedTenantVisibility(visibility)
if err != nil {
return nil, err
}
config["visibility"] = normalizedVisibility
}
orgUnitType := strings.TrimSpace(seedTenantCSVValue(row, header, "org_unit_type"))
if orgUnitType != "" {
if !isAllowedSeedTenantOrgUnitType(orgUnitType) {
return nil, errors.New("orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속")
}
config["orgUnitType"] = orgUnitType
}
if worksmobileSync := strings.TrimSpace(seedTenantCSVValue(row, header, "worksmobile_sync")); worksmobileSync != "" {
excluded, err := normalizeSeedTenantWorksmobileExcluded(worksmobileSync)
if err != nil {
return nil, err
}
config["worksmobileExcluded"] = excluded
}
if len(config) == 0 {
return nil, nil
}
return config, nil
}
func normalizeSeedTenantWorksmobileExcluded(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동":
return false, nil
case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외":
return true, nil
default:
return false, errors.New("worksmobile_sync must be yes or no")
}
}
func normalizeSeedTenantVisibility(value string) (string, error) {
visibility := strings.ToLower(strings.TrimSpace(value))
if visibility == "" || visibility == "public" {
return "public", nil
}
if visibility != "internal" && visibility != "private" {
return "", errors.New("visibility must be public, internal, or private")
}
return visibility, nil
}
func isAllowedSeedTenantOrgUnitType(value string) bool {
switch strings.TrimSpace(value) {
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
return true
default:
return false
}
}
func seedTenantCSVRowIsEmpty(row []string) bool {
for _, value := range row {
if strings.TrimSpace(value) != "" {
@@ -405,6 +488,7 @@ func createSeedTenant(
Description: config.Description,
Status: domain.TenantStatusActive,
ParentID: parentID,
Config: config.Config,
}
if err := repo.Create(ctx, tenant); err != nil {

View File

@@ -61,6 +61,7 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
slug: "baron-group",
tenantType: domain.TenantTypeCompanyGroup,
parentSlug: "hanmac-family",
domains: []string{"brsw.kr"},
},
{
name: "(주)장헌",
@@ -78,10 +79,10 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
},
{
name: "한라산업개발",
slug: "hanlla",
slug: "halla",
tenantType: domain.TenantTypeCompany,
parentSlug: "baron-group",
domains: []string{"hanllasanup.co.kr"},
parentSlug: "hanmac-family",
domains: []string{"hallasanup.com"},
},
{
name: "(주)피티씨",
@@ -97,30 +98,64 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
},
}
if len(configs) != len(expected) {
t.Fatalf("expected %d seed tenants, got %d", len(expected), len(configs))
if len(configs) < len(expected) {
t.Fatalf("expected at least %d seed tenants, got %d", len(expected), len(configs))
}
for i, want := range expected {
got := configs[i]
wantFamilyChildOrder := []string{
"gpdtdc",
"saman",
"hanmac",
"baron-group",
"halla",
}
policyFamilyChildSlugs := map[string]bool{}
for _, slug := range wantFamilyChildOrder {
policyFamilyChildSlugs[slug] = true
}
gotFamilyChildOrder := make([]string, 0, len(wantFamilyChildOrder))
for _, config := range configs {
if config.ParentSlug == "hanmac-family" && policyFamilyChildSlugs[config.Slug] {
gotFamilyChildOrder = append(gotFamilyChildOrder, config.Slug)
}
}
if len(gotFamilyChildOrder) != len(wantFamilyChildOrder) {
t.Fatalf("hanmac-family child order = %#v, want %#v", gotFamilyChildOrder, wantFamilyChildOrder)
}
for i, wantSlug := range wantFamilyChildOrder {
if gotFamilyChildOrder[i] != wantSlug {
t.Fatalf("hanmac-family child order[%d] = %q, want %q", i, gotFamilyChildOrder[i], wantSlug)
}
}
configBySlug := make(map[string]InitialTenantConfig, len(configs))
for _, config := range configs {
configBySlug[config.Slug] = config
}
for _, want := range expected {
got, ok := configBySlug[want.slug]
if !ok {
t.Fatalf("tenant slug %q not found in seed configs", want.slug)
}
if got.Name != want.name {
t.Fatalf("tenant[%d] name = %q, want %q", i, got.Name, want.name)
t.Fatalf("tenant[%s] name = %q, want %q", want.slug, got.Name, want.name)
}
if got.Slug != want.slug {
t.Fatalf("tenant[%d] slug = %q, want %q", i, got.Slug, want.slug)
t.Fatalf("tenant[%s] slug = %q, want %q", want.slug, got.Slug, want.slug)
}
if got.Type != want.tenantType {
t.Fatalf("tenant[%d] type = %q, want %q", i, got.Type, want.tenantType)
t.Fatalf("tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType)
}
if got.ParentSlug != want.parentSlug {
t.Fatalf("tenant[%d] parent slug = %q, want %q", i, got.ParentSlug, want.parentSlug)
t.Fatalf("tenant[%s] parent slug = %q, want %q", want.slug, got.ParentSlug, want.parentSlug)
}
if len(got.Domains) != len(want.domains) {
t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains)
t.Fatalf("tenant[%s] domains = %#v, want %#v", want.slug, got.Domains, want.domains)
}
for j, wantDomain := range want.domains {
if got.Domains[j] != wantDomain {
t.Fatalf("tenant[%d] domain[%d] = %q, want %q", i, j, got.Domains[j], wantDomain)
t.Fatalf("tenant[%s] domain[%d] = %q, want %q", want.slug, j, got.Domains[j], wantDomain)
}
}
}
@@ -135,9 +170,9 @@ func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"Root,COMPANY_GROUP,,root,Root memo,\n" +
"Child,COMPANY,root,child,Child memo,child.example.com\n"
csv := "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" +
"Root,COMPANY_GROUP,,root,Root memo,,,,\n" +
"Child,USER_GROUP,root,child,Child memo,child.example.com,private,팀,no\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
}
@@ -156,6 +191,41 @@ func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
if len(configs[1].Domains) != 1 || configs[1].Domains[0] != "child.example.com" {
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
}
if configs[1].Config["visibility"] != "private" {
t.Fatalf("child visibility = %#v, want private", configs[1].Config["visibility"])
}
if configs[1].Config["orgUnitType"] != "팀" {
t.Fatalf("child orgUnitType = %#v, want 팀", configs[1].Config["orgUnitType"])
}
if configs[1].Config["worksmobileExcluded"] != true {
t.Fatalf("child worksmobileExcluded = %#v, want true", configs[1].Config["worksmobileExcluded"])
}
}
func TestSeedTenantCSVDefinesMHDAsPrivateUserGroup(t *testing.T) {
configs, err := loadSeedTenantConfigs()
if err != nil {
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
}
configBySlug := make(map[string]InitialTenantConfig, len(configs))
for _, config := range configs {
configBySlug[config.Slug] = config
}
mhd, ok := configBySlug["mhd"]
if !ok {
t.Fatal("mhd seed tenant not found")
}
if mhd.Type != domain.TenantTypeUserGroup {
t.Fatalf("mhd type = %q, want %q", mhd.Type, domain.TenantTypeUserGroup)
}
if mhd.Config["visibility"] != "private" {
t.Fatalf("mhd visibility = %#v, want private", mhd.Config["visibility"])
}
if mhd.Config["worksmobileExcluded"] != true {
t.Fatalf("mhd worksmobileExcluded = %#v, want true", mhd.Config["worksmobileExcluded"])
}
}
func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) {

View File

@@ -117,16 +117,18 @@ type tenantDomainConflict struct {
}
type tenantCSVRecord struct {
TenantID string
Name string
Type string
ParentTenantID *string
ParentTenantSlug string
Slug string
Memo string
Domains []string
Visibility string
OrgUnitType string
TenantID string
Name string
Type string
ParentTenantID *string
ParentTenantSlug string
Slug string
Memo string
Domains []string
Visibility string
OrgUnitType string
WorksmobileSync string
WorksmobileSyncSet bool
}
type orgContextTenant struct {
@@ -420,10 +422,10 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
writer := csv.NewWriter(&buf)
includeIDs := includeCSVIds(c)
if includeIDs {
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type"}); err != nil {
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
} else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type"}); err != nil {
} else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
slugByID := make(map[string]string, len(allTenants))
@@ -444,7 +446,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
domains = append(domains, domainName)
}
}
visibility, orgUnitType := tenantCSVOrgConfigValues(tenant.Config)
visibility, orgUnitType, worksmobileSync := tenantCSVOrgConfigValues(tenant.Config)
row := []string{
tenant.Name,
tenant.Type,
@@ -454,6 +456,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
strings.Join(domains, ";"),
visibility,
orgUnitType,
worksmobileSync,
}
if includeIDs {
row = []string{
@@ -467,6 +470,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
strings.Join(domains, ";"),
visibility,
orgUnitType,
worksmobileSync,
}
}
if err := writer.Write(row); err != nil {
@@ -683,17 +687,20 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
parentID = &parentValue
}
worksmobileSync, worksmobileSyncSet := tenantCSVWorksmobileSyncValue(row, header)
records = append(records, tenantCSVRecord{
TenantID: tenantCSVValue(row, header, "tenant_id"),
Name: name,
Type: tenantType,
ParentTenantID: parentID,
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
Slug: slug,
Memo: tenantCSVValue(row, header, "memo"),
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
Visibility: tenantCSVValue(row, header, "visibility"),
OrgUnitType: tenantCSVValue(row, header, "org_unit_type"),
TenantID: tenantCSVValue(row, header, "tenant_id"),
Name: name,
Type: tenantType,
ParentTenantID: parentID,
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
Slug: slug,
Memo: tenantCSVValue(row, header, "memo"),
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
Visibility: tenantCSVValue(row, header, "visibility"),
OrgUnitType: tenantCSVValue(row, header, "org_unit_type"),
WorksmobileSync: worksmobileSync,
WorksmobileSyncSet: worksmobileSyncSet,
})
}
@@ -703,35 +710,42 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
func tenantCSVHeaderIndex(header []string) map[string]int {
index := make(map[string]int, len(header))
aliases := map[string]string{
"id": "tenant_id",
"tenantid": "tenant_id",
"tenant_id": "tenant_id",
"name": "name",
"type": "type",
"parentid": "parent_tenant_id",
"parent_id": "parent_tenant_id",
"parenttenantid": "parent_tenant_id",
"parent_tenant_id": "parent_tenant_id",
"parenttenantslug": "parent_tenant_slug",
"parent_tenant_slug": "parent_tenant_slug",
"slug": "slug",
"memo": "memo",
"description": "memo",
"email-domain": "email_domain",
"emaildomain": "email_domain",
"email_domain": "email_domain",
"domain": "email_domain",
"domains": "email_domain",
"visibility": "visibility",
"public_setting": "visibility",
"publicsetting": "visibility",
"orgunittype": "org_unit_type",
"org_unit_type": "org_unit_type",
"org-unit-type": "org_unit_type",
"organizationtype": "org_unit_type",
"organization_type": "org_unit_type",
"orgtype": "org_unit_type",
"org_type": "org_unit_type",
"id": "tenant_id",
"tenantid": "tenant_id",
"tenant_id": "tenant_id",
"name": "name",
"type": "type",
"parentid": "parent_tenant_id",
"parent_id": "parent_tenant_id",
"parenttenantid": "parent_tenant_id",
"parent_tenant_id": "parent_tenant_id",
"parenttenantslug": "parent_tenant_slug",
"parent_tenant_slug": "parent_tenant_slug",
"slug": "slug",
"memo": "memo",
"description": "memo",
"email-domain": "email_domain",
"emaildomain": "email_domain",
"email_domain": "email_domain",
"domain": "email_domain",
"domains": "email_domain",
"visibility": "visibility",
"public_setting": "visibility",
"publicsetting": "visibility",
"orgunittype": "org_unit_type",
"org_unit_type": "org_unit_type",
"org-unit-type": "org_unit_type",
"organizationtype": "org_unit_type",
"organization_type": "org_unit_type",
"orgtype": "org_unit_type",
"org_type": "org_unit_type",
"worksmobile": "worksmobile_sync",
"worksmobilesync": "worksmobile_sync",
"worksmobile_sync": "worksmobile_sync",
"works_sync": "worksmobile_sync",
"works": "worksmobile_sync",
"worksmobileexcluded": "worksmobile_excluded",
"worksmobile_excluded": "worksmobile_excluded",
}
for i, column := range header {
key := strings.ToLower(strings.TrimSpace(column))
@@ -751,6 +765,28 @@ func tenantCSVValue(row []string, header map[string]int, key string) string {
return strings.TrimSpace(row[idx])
}
func tenantCSVWorksmobileSyncValue(row []string, header map[string]int) (string, bool) {
if _, ok := header["worksmobile_sync"]; ok {
value := tenantCSVValue(row, header, "worksmobile_sync")
if value == "" {
return "yes", true
}
return value, true
}
if _, ok := header["worksmobile_excluded"]; ok {
value := tenantCSVValue(row, header, "worksmobile_excluded")
excluded, err := normalizeTenantWorksmobileExcluded(value)
if err == nil && excluded {
return "no", true
}
if err == nil {
return "yes", true
}
return value, true
}
return "", false
}
func tenantCSVRowIsEmpty(row []string) bool {
for _, value := range row {
if strings.TrimSpace(value) != "" {
@@ -872,11 +908,38 @@ func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
normalized[key] = orgUnitType
continue
}
if key == "worksmobileExcluded" {
excluded, err := normalizeTenantWorksmobileExcluded(value)
if err != nil {
return nil, err
}
normalized[key] = excluded
continue
}
normalized[key] = value
}
return normalized, nil
}
func normalizeTenantWorksmobileExcluded(value any) (bool, error) {
switch typed := value.(type) {
case bool:
return typed, nil
case string:
normalized := strings.ToLower(strings.TrimSpace(typed))
switch normalized {
case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동":
return false, nil
case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외":
return true, nil
default:
return false, fmt.Errorf("worksmobile_sync must be yes or no")
}
default:
return false, fmt.Errorf("worksmobile_sync must be yes or no")
}
}
func isAllowedOrgUnitType(value string) bool {
switch value {
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
@@ -948,10 +1011,14 @@ func tenantVisibility(config domain.JSONMap) string {
}
}
func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string) {
func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string, string) {
visibility := tenantVisibility(config)
orgUnitType, _ := config["orgUnitType"].(string)
return visibility, strings.TrimSpace(orgUnitType)
worksmobileSync := "yes"
if excluded, err := normalizeTenantWorksmobileExcluded(config["worksmobileExcluded"]); err == nil && excluded {
worksmobileSync = "no"
}
return visibility, strings.TrimSpace(orgUnitType), worksmobileSync
}
func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) {
@@ -962,6 +1029,9 @@ func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) {
if strings.TrimSpace(record.OrgUnitType) != "" {
config["orgUnitType"] = record.OrgUnitType
}
if record.WorksmobileSyncSet {
config["worksmobileExcluded"] = record.WorksmobileSync
}
if len(config) == 0 {
return nil, nil
}
@@ -2319,7 +2389,7 @@ func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant {
for _, domain := range tenant.Domains {
domains = append(domains, domain.Domain)
}
visibility, orgUnitType := tenantCSVOrgConfigValues(tenant.Config)
visibility, orgUnitType, _ := tenantCSVOrgConfigValues(tenant.Config)
return orgContextTenant{
ID: tenant.ID,
Type: tenant.Type,

View File

@@ -991,8 +991,8 @@ func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터")
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터,yes")
}
func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) {
@@ -1027,7 +1027,7 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T)
text := string(body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
assert.Contains(t, text, "Child Tenant,USER_GROUP,parent-tenant,child-tenant,,")
assert.NotContains(t, text, "tenant_id")
assert.NotContains(t, text, "parent_tenant_id")
@@ -1114,7 +1114,7 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t
text := string(body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type")
assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync")
assert.Contains(t, text, childID+",Child Org,ORGANIZATION,"+parentID+",parent-org,child-org,")
assert.Contains(t, text, grandchildID+",Leaf Team,USER_GROUP,"+childID+",child-org,leaf-team,")
assert.NotContains(t, text, unrelatedID)
@@ -1309,8 +1309,8 @@ func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
records, err := parseTenantCSVRecords(strings.NewReader(
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n" +
"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터\n",
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" +
"Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터,no\n",
))
assert.NoError(t, err)
@@ -1318,6 +1318,10 @@ func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) {
assert.Equal(t, []string{"samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"}, records[0].Domains)
assert.Equal(t, "internal", records[0].Visibility)
assert.Equal(t, "센터", records[0].OrgUnitType)
assert.Equal(t, "no", records[0].WorksmobileSync)
config, err := tenantCSVRecordConfig(records[0])
assert.NoError(t, err)
assert.Equal(t, true, config["worksmobileExcluded"])
}
func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) {
@@ -1378,13 +1382,15 @@ func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) {
func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) {
config, err := normalizeTenantConfig(map[string]any{
"visibility": "internal",
"orgUnitType": "센터",
"visibility": "internal",
"orgUnitType": "센터",
"worksmobileExcluded": true,
})
assert.NoError(t, err)
assert.Equal(t, "internal", config["visibility"])
assert.Equal(t, "센터", config["orgUnitType"])
assert.Equal(t, true, config["worksmobileExcluded"])
}
func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) {

View File

@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
@@ -53,6 +54,8 @@ func (m *MockUserGroupService) List(ctx context.Context, tenantID string) ([]dom
return args.Get(0).([]domain.UserGroup), args.Error(1)
}
func (m *MockUserGroupService) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {}
func (m *MockUserGroupService) AddMember(ctx context.Context, groupID, userID string) error {
return m.Called(ctx, groupID, userID).Error(0)
}

View File

@@ -99,6 +99,70 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
return sanitized
}
func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService service.TenantService, metadata map[string]any, appointments []map[string]any) (bool, error) {
if tenantService == nil || metadata == nil {
return false, nil
}
cleared := false
clearMetadataPrimary := func() {
delete(metadata, "primaryTenantId")
delete(metadata, "primaryTenantSlug")
delete(metadata, "primaryTenantName")
delete(metadata, "primaryTenantIsOwner")
cleared = true
}
if isNonPublicRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
clearMetadataPrimary()
}
clearAppointment := func(appointment map[string]any) {
if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary", "representative", "isRepresentative"); !ok || !isPrimary {
return
}
tenantID := normalizeMetadataString(appointment["tenantId"])
tenantSlug := normalizeMetadataString(appointment["tenantSlug"])
if tenantSlug == "" {
tenantSlug = normalizeMetadataString(appointment["slug"])
}
if !isNonPublicRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
return
}
appointment["isPrimary"] = false
appointment["primary"] = false
appointment["representative"] = false
appointment["isRepresentative"] = false
clearMetadataPrimary()
}
for _, appointment := range appointments {
clearAppointment(appointment)
}
if rawAppointments, ok := metadata["additionalAppointments"].([]any); ok {
for _, rawAppointment := range rawAppointments {
if appointment, ok := rawAppointment.(map[string]any); ok {
clearAppointment(appointment)
}
}
}
return cleared, nil
}
func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
var tenant *domain.Tenant
var err error
if strings.TrimSpace(tenantID) != "" {
tenant, err = tenantService.GetTenant(ctx, strings.TrimSpace(tenantID))
} else if strings.TrimSpace(tenantSlug) != "" {
tenant, err = tenantService.GetTenantBySlug(ctx, strings.TrimSpace(tenantSlug))
}
if err != nil || tenant == nil {
return false
}
visibility := tenantVisibility(tenant.Config)
return visibility == "internal" || visibility == "private"
}
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
if value := strings.TrimSpace(primaryTenantID); value != "" {
return value
@@ -651,6 +715,20 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
req.CompanyCode = tenantSlug
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
representativeCleared := false
if h.TenantService != nil {
cleared, err := sanitizeUserRepresentativeTenants(c.Context(), h.TenantService, req.Metadata, req.AdditionalAppointments)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
representativeCleared = cleared
if cleared {
req.PrimaryTenantID = ""
req.PrimaryTenantName = ""
req.PrimaryTenantIsOwner = nil
req.CompanyCode = ""
}
}
email := strings.TrimSpace(req.Email)
if email == "" {
@@ -725,7 +803,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
// [Resolve TenantID and Custom Login IDs before Kratos creation]
var tenantID string
requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments)
primaryAppointments := req.AdditionalAppointments
if representativeCleared {
primaryAppointments = nil
}
requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, primaryAppointments)
if req.CompanyCode == "" && h.TenantService != nil {
if requestedPrimaryTenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
@@ -1971,6 +2053,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
req.CompanyCode = tenantSlug
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
if h.TenantService != nil {
cleared, err := sanitizeUserRepresentativeTenants(c.Context(), h.TenantService, req.Metadata, req.AdditionalAppointments)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
if cleared {
req.PrimaryTenantID = ""
req.PrimaryTenantName = ""
req.PrimaryTenantIsOwner = nil
req.CompanyCode = nil
}
}
if req.Role != nil {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")

View File

@@ -205,6 +205,49 @@ func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
assert.Contains(t, metadata, "userType")
}
func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
mockTenant := new(MockTenantServiceForUser)
internalTenantID := "internal-tenant"
publicTenantID := "public-tenant"
metadata := map[string]any{
"primaryTenantId": internalTenantID,
"primaryTenantName": "비공개팀",
"primaryTenantSlug": "private-team",
"additionalAppointments": []any{
map[string]any{"tenantId": internalTenantID, "tenantSlug": "private-team", "isPrimary": true},
map[string]any{"tenantId": publicTenantID, "tenantSlug": "public-team", "isPrimary": false},
},
}
appointments := []map[string]any{
{"tenantId": internalTenantID, "tenantSlug": "private-team", "isPrimary": true},
{"tenantId": publicTenantID, "tenantSlug": "public-team", "isPrimary": false},
}
mockTenant.On("GetTenant", mock.Anything, internalTenantID).Return(&domain.Tenant{
ID: internalTenantID,
Slug: "private-team",
Config: domain.JSONMap{"visibility": "private"},
}, nil)
mockTenant.On("GetTenant", mock.Anything, publicTenantID).Return(&domain.Tenant{
ID: publicTenantID,
Slug: "public-team",
Config: domain.JSONMap{"visibility": "public"},
}, nil).Maybe()
cleared, err := sanitizeUserRepresentativeTenants(context.Background(), mockTenant, metadata, appointments)
require.NoError(t, err)
assert.True(t, cleared)
assert.NotContains(t, metadata, "primaryTenantId")
assert.NotContains(t, metadata, "primaryTenantName")
assert.NotContains(t, metadata, "primaryTenantSlug")
assert.Equal(t, false, appointments[0]["isPrimary"])
metadataAppointments := metadata["additionalAppointments"].([]any)
firstAppointment := metadataAppointments[0].(map[string]any)
assert.Equal(t, false, firstAppointment["isPrimary"])
mockTenant.AssertExpectations(t)
}
type MockTenantServiceForUser struct {
mock.Mock
service.TenantService

View File

@@ -105,6 +105,14 @@ func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error {
return c.JSON(job)
}
func (h *WorksmobileHandler) DeletePendingJobs(c *fiber.Ctx) error {
result, err := h.Service.DeletePendingJobs(c.Context(), strings.TrimSpace(c.Params("tenantId")))
if err != nil {
return worksmobileGuardError(c, err, "delete_pending_jobs")
}
return c.JSON(result)
}
func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error {
credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId")))
if err != nil {

View File

@@ -153,6 +153,24 @@ func TestWorksmobileHandlerDeletesCredentialBatchPasswords(t *testing.T) {
require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID)
}
func TestWorksmobileHandlerDeletesPendingJobs(t *testing.T) {
fakeService := &fakeWorksmobileAdminService{
pendingJobsDeleteResult: service.WorksmobilePendingJobDeleteResult{DeletedCount: 3},
}
h := NewWorksmobileHandler(fakeService)
app := fiber.New()
app.Delete("/tenants/:tenantId/worksmobile/jobs/pending", h.DeletePendingJobs)
resp, err := app.Test(httptest.NewRequest("DELETE", "/tenants/hanmac-id/worksmobile/jobs/pending", nil))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, "hanmac-id", fakeService.deletedPendingJobsTenantID)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), `"deletedCount":3`)
}
func TestWorksmobileHandlerLogsActionFailures(t *testing.T) {
var logs bytes.Buffer
previous := slog.Default()
@@ -184,6 +202,8 @@ type fakeWorksmobileAdminService struct {
resetPasswordCredentialBatchID string
downloadCredentialBatchID string
deletedCredentialBatchID string
deletedPendingJobsTenantID string
pendingJobsDeleteResult service.WorksmobilePendingJobDeleteResult
credentialBatches []service.WorksmobileCredentialBatch
}
@@ -237,3 +257,8 @@ func (f *fakeWorksmobileAdminService) DeleteCredentialBatchPasswords(ctx context
f.deletedCredentialBatchID = credentialBatchID
return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil
}
func (f *fakeWorksmobileAdminService) DeletePendingJobs(ctx context.Context, tenantID string) (service.WorksmobilePendingJobDeleteResult, error) {
f.deletedPendingJobsTenantID = tenantID
return f.pendingJobsDeleteResult, nil
}

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
}
// Auto-migrate
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{})
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{}, &domain.WorksmobileOutbox{})
if err != nil {
log.Fatalf("failed to migrate database: %s", err)
}

View File

@@ -14,10 +14,11 @@ type WorksmobileOutboxRepository interface {
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error)
ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error)
MarkRetry(ctx context.Context, id string) error
MarkProcessing(ctx context.Context, id string) error
MarkProcessing(ctx context.Context, id string) (bool, error)
MarkProcessed(ctx context.Context, id string) error
MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error
}
@@ -76,16 +77,88 @@ func (r *worksmobileOutboxRepository) UpdatePayload(ctx context.Context, id stri
}).Error
}
func (r *worksmobileOutboxRepository) DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) {
result := r.db.WithContext(ctx).
Where("status = ? AND payload ->> 'tenantRootId' = ?", domain.WorksmobileOutboxStatusPending, tenantRootID).
Delete(&domain.WorksmobileOutbox{})
return result.RowsAffected, result.Error
}
func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
var rows []domain.WorksmobileOutbox
err := r.db.WithContext(ctx).
Where("status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", domain.WorksmobileOutboxStatusPending, time.Now()).
Order("created_at asc").
Limit(limit).
Find(&rows).Error
err := r.db.WithContext(ctx).Raw(`
WITH RECURSIVE candidates AS (
SELECT
*,
NULLIF(payload #>> '{request,orgUnitExternalKey}', '') AS org_external_key,
CASE
WHEN payload #>> '{request,parentOrgUnitId}' LIKE 'externalKey:%'
THEN NULLIF(substr(payload #>> '{request,parentOrgUnitId}', length('externalKey:') + 1), '')
ELSE ''
END AS parent_external_key
FROM worksmobile_outboxes
WHERE status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
),
ready AS (
SELECT candidates.*
FROM candidates
WHERE NOT (
candidates.resource_type = ?
AND candidates.action = ?
AND candidates.parent_external_key <> ''
AND EXISTS (
SELECT 1
FROM worksmobile_outboxes parent_job
WHERE parent_job.resource_type = ?
AND parent_job.action = ?
AND parent_job.status <> ?
AND NULLIF(parent_job.payload #>> '{request,orgUnitExternalKey}', '') = candidates.parent_external_key
)
)
),
org_depth AS (
SELECT id, org_external_key, parent_external_key, 0 AS depth
FROM ready
UNION ALL
SELECT child.id, child.org_external_key, child.parent_external_key, parent.depth + 1
FROM ready child
JOIN org_depth parent ON child.parent_external_key = parent.org_external_key
WHERE child.resource_type = ? AND child.action = ? AND parent.depth < 64
)
SELECT ready.*
FROM ready
LEFT JOIN LATERAL (
SELECT max(depth) AS dependency_depth
FROM org_depth
WHERE org_depth.id = ready.id
) AS depth_rank ON true
ORDER BY
CASE
WHEN ready.resource_type = ? AND ready.action = ? THEN 0
WHEN ready.resource_type = ? THEN 1
ELSE 2
END ASC,
COALESCE(depth_rank.dependency_depth, 0) ASC,
ready.created_at ASC
LIMIT ?
`,
domain.WorksmobileOutboxStatusPending,
time.Now(),
domain.WorksmobileResourceOrgUnit,
domain.WorksmobileActionUpsert,
domain.WorksmobileResourceOrgUnit,
domain.WorksmobileActionUpsert,
domain.WorksmobileOutboxStatusProcessed,
domain.WorksmobileResourceOrgUnit,
domain.WorksmobileActionUpsert,
domain.WorksmobileResourceOrgUnit,
domain.WorksmobileActionUpsert,
domain.WorksmobileResourceUser,
limit,
).Scan(&rows).Error
return rows, err
}
@@ -106,11 +179,12 @@ func (r *worksmobileOutboxRepository) MarkRetry(ctx context.Context, id string)
}).Error
}
func (r *worksmobileOutboxRepository) MarkProcessing(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ? AND status = ?", id, domain.WorksmobileOutboxStatusPending).Updates(map[string]any{
func (r *worksmobileOutboxRepository) MarkProcessing(ctx context.Context, id string) (bool, error) {
result := r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ? AND status = ?", id, domain.WorksmobileOutboxStatusPending).Updates(map[string]any{
"status": domain.WorksmobileOutboxStatusProcessing,
"updated_at": time.Now(),
}).Error
})
return result.RowsAffected > 0, result.Error
}
func (r *worksmobileOutboxRepository) MarkProcessed(ctx context.Context, id string) error {

View File

@@ -0,0 +1,125 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background()
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
rows := []domain.WorksmobileOutbox{
{
ID: "00000000-0000-0000-0000-000000000101",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-pending",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
DedupeKey: "pending-root",
Payload: domain.JSONMap{"tenantRootId": "root-1"},
},
{
ID: "00000000-0000-0000-0000-000000000102",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-other-root",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
DedupeKey: "pending-other-root",
Payload: domain.JSONMap{"tenantRootId": "root-2"},
},
{
ID: "00000000-0000-0000-0000-000000000103",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-failed",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "failed-root",
Payload: domain.JSONMap{"tenantRootId": "root-1"},
},
{
ID: "00000000-0000-0000-0000-000000000104",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "org-processed",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
DedupeKey: "processed-root",
Payload: domain.JSONMap{"tenantRootId": "root-1"},
},
}
for i := range rows {
require.NoError(t, repo.Create(ctx, &rows[i]))
}
deleted, err := repo.DeletePendingByTenantRoot(ctx, "root-1")
require.NoError(t, err)
require.Equal(t, int64(1), deleted)
var remaining []domain.WorksmobileOutbox
require.NoError(t, testDB.Order("id asc").Find(&remaining).Error)
require.Len(t, remaining, 3)
require.Equal(t, "00000000-0000-0000-0000-000000000102", remaining[0].ID)
require.Equal(t, "00000000-0000-0000-0000-000000000103", remaining[1].ID)
require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID)
}
func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background()
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
baseTime := time.Date(2026, 6, 2, 15, 21, 0, 0, time.UTC)
child := domain.WorksmobileOutbox{
ID: "00000000-0000-0000-0000-000000000201",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "child-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
DedupeKey: "orgunit:upsert:child-tenant",
Payload: domain.JSONMap{
"request": map[string]any{
"orgUnitExternalKey": "child-tenant",
"parentOrgUnitId": "externalKey:parent-tenant",
},
},
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
parent := domain.WorksmobileOutbox{
ID: "00000000-0000-0000-0000-000000000202",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "parent-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
DedupeKey: "orgunit:upsert:parent-tenant",
Payload: domain.JSONMap{
"request": map[string]any{
"orgUnitExternalKey": "parent-tenant",
},
},
CreatedAt: baseTime.Add(time.Second),
UpdatedAt: baseTime.Add(time.Second),
}
require.NoError(t, testDB.Create(&child).Error)
require.NoError(t, testDB.Create(&parent).Error)
rows, err := repo.ListReady(ctx, 10)
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, "parent-tenant", rows[0].ResourceID)
require.NoError(t, repo.MarkProcessed(ctx, parent.ID))
rows, err = repo.ListReady(ctx, 10)
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, "child-tenant", rows[0].ResourceID)
}

View File

@@ -17,6 +17,7 @@ type UserGroupService interface {
List(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
Delete(ctx context.Context, tenantID, groupID string) error
Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error)
SetWorksmobileSyncer(syncer WorksmobileSyncer)
// Member Management with Keto Sync
AddMember(ctx context.Context, groupID, userID string) error
@@ -35,6 +36,7 @@ type userGroupService struct {
ketoService KetoService
outboxRepo repository.KetoOutboxRepository
kratos KratosAdminService
worksmobile WorksmobileSyncer
}
func NewUserGroupService(
@@ -55,6 +57,10 @@ func NewUserGroupService(
}
}
func (s *userGroupService) SetWorksmobileSyncer(syncer WorksmobileSyncer) {
s.worksmobile = syncer
}
func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) {
// For Keto and Tenant hierarchy, if no parent group, the company tenant is the parent.
actualParentID := parentID
@@ -261,6 +267,10 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
localUser.Department = group.Name
if err := s.userRepo.Update(ctx, localUser); err != nil {
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
} else if s.worksmobile != nil {
if err := s.worksmobile.EnqueueUserUpsertIfInScope(ctx, *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
}
}
}
}

View File

@@ -139,6 +139,27 @@ func (m *MockUserRepository) DB() *gorm.DB {
return nil
}
type fakeUserGroupWorksmobileSyncer struct {
userUpserts []domain.User
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error {
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
f.userUpserts = append(f.userUpserts, user)
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
return nil
}
type MockKetoOutboxRepository struct {
mock.Mock
}
@@ -337,6 +358,57 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
mockKratos.AssertExpectations(t)
}
func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)
mockUserRepo := new(MockUserRepository)
mockTenantRepo := new(MockTenantRepository)
mockKratos := new(MockKratosAdminServiceShared)
worksmobile := &fakeUserGroupWorksmobileSyncer{}
svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos)
svc.SetWorksmobileSyncer(worksmobile)
groupID := "group-1"
userID := "user-1"
tenantID := "tenant-1"
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil)
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{
ID: userID,
Email: "user@test.com",
Name: "User Test",
Status: "active",
}, nil)
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: "tenant-slug"}, nil)
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID,
Traits: map[string]any{"email": "user@test.com"},
State: "active",
}, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{
ID: userID,
Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID, "department": "Sales"},
State: "active",
}, nil)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil).Once()
err := svc.AddMember(context.Background(), groupID, userID)
assert.NoError(t, err)
assert.Len(t, worksmobile.userUpserts, 1)
assert.Equal(t, userID, worksmobile.userUpserts[0].ID)
assert.NotNil(t, worksmobile.userUpserts[0].TenantID)
assert.Equal(t, tenantID, *worksmobile.userUpserts[0].TenantID)
assert.Equal(t, "Sales", worksmobile.userUpserts[0].Department)
mockOutbox.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)

View File

@@ -658,6 +658,84 @@ func TestWorksmobileRelayWorkerProcessesOrgUnitDeleteAndMarksProcessed(t *testin
require.Equal(t, []string{"works-org-1"}, client.deletedOrgUnits)
}
func TestWorksmobileRelayWorkerProcessesOrgUnitParentsBeforeChildren(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-child",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "child-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: domain.JSONMap{
"request": map[string]any{
"domainId": 300293726,
"orgUnitExternalKey": "child-tenant",
"orgUnitName": "child",
"parentOrgUnitId": "externalKey:parent-tenant",
},
},
},
{
ID: "job-parent",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "parent-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: domain.JSONMap{
"request": map[string]any{
"domainId": 300293726,
"orgUnitExternalKey": "parent-tenant",
"orgUnitName": "parent",
},
},
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-parent", "job-child"}, repo.processingIDs)
require.Equal(t, []string{"parent-tenant", "child-tenant"}, []string{
client.createdOrgUnits[0].OrgUnitExternalKey,
client.createdOrgUnits[1].OrgUnitExternalKey,
})
}
func TestWorksmobileRelayWorkerSkipsDispatchWhenJobClaimFails(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
markProcessingClaims: map[string]bool{"job-claimed-by-other-worker": false},
ready: []domain.WorksmobileOutbox{
{
ID: "job-claimed-by-other-worker",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "org-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: domain.JSONMap{
"request": map[string]any{
"domainId": 300293726,
"orgUnitExternalKey": "org-1",
"orgUnitName": "org",
},
},
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Empty(t, repo.processingIDs)
require.Empty(t, repo.processedIDs)
require.Empty(t, client.createdOrgUnits)
}
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
jobs := []domain.WorksmobileOutbox{
{
@@ -1094,14 +1172,17 @@ func boolPtr(value bool) *bool {
}
type fakeWorksmobileOutboxRepo struct {
recent []domain.WorksmobileOutbox
ready []domain.WorksmobileOutbox
created []domain.WorksmobileOutbox
credentialBatchJobs []domain.WorksmobileOutbox
payloadUpdates []domain.JSONMap
processingIDs []string
processedIDs []string
failedIDs []string
recent []domain.WorksmobileOutbox
ready []domain.WorksmobileOutbox
created []domain.WorksmobileOutbox
credentialBatchJobs []domain.WorksmobileOutbox
payloadUpdates []domain.JSONMap
deletedPendingTenantRootID string
deletedPendingCount int
markProcessingClaims map[string]bool
processingIDs []string
processedIDs []string
failedIDs []string
}
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
@@ -1137,6 +1218,11 @@ func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string
return nil
}
func (f *fakeWorksmobileOutboxRepo) DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) {
f.deletedPendingTenantRootID = tenantRootID
return int64(f.deletedPendingCount), nil
}
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
return f.ready, nil
}
@@ -1149,9 +1235,12 @@ func (f *fakeWorksmobileOutboxRepo) MarkRetry(ctx context.Context, id string) er
return nil
}
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) error {
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) (bool, error) {
if f.markProcessingClaims != nil && !f.markProcessingClaims[id] {
return false, nil
}
f.processingIDs = append(f.processingIDs, id)
return nil
return true, nil
}
func (f *fakeWorksmobileOutboxRepo) MarkProcessed(ctx context.Context, id string) error {

View File

@@ -126,6 +126,9 @@ func buildWorksmobileOrgUnitEmail(tenant domain.Tenant, domainTenant domain.Tena
func worksmobileTenantMailDomain(tenant domain.Tenant) string {
envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID")
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv("WORKS_DEFAULT_DOMAIN_" + envKey))); domainName != "" {
return domainName
}
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" {
return domainName
}
@@ -136,6 +139,8 @@ func worksmobileTenantMailDomain(tenant domain.Tenant) string {
return "hanmaceng.co.kr"
case "GPDTDC":
return "baroncs.co.kr"
case "HALLA":
return "hallasanup.com"
case "BARONGROUP":
return "brsw.kr"
default:
@@ -493,6 +498,10 @@ func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant dom
if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
return domainID, nil
}
case "hallasanup.com":
if domainID, ok := worksmobileDomainIDFromEnv("HALLA_DOMAIN_ID"); ok {
return domainID, nil
}
case "brsw.kr":
if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
return domainID, nil
@@ -524,6 +533,8 @@ func worksmobileDomainIDEnvKeyFromEmail(email string) string {
return "HANMAC_DOMAIN_ID"
case "baroncs.co.kr":
return "GPDTDC_DOMAIN_ID"
case "hallasanup.com":
return "HALLA_DOMAIN_ID"
case "brsw.kr":
return "BARONGROUP_DOMAIN_ID"
default:
@@ -574,6 +585,9 @@ func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
if tenantMatchesAny(tenant, "gpdtdc", "총괄", "기술개발센터", "기술개발") {
return "GPDTDC_DOMAIN_ID"
}
if isHallaWorksmobileTenant(tenant) {
return "HALLA_DOMAIN_ID"
}
return "BARONGROUP_DOMAIN_ID"
}
@@ -595,6 +609,7 @@ func worksmobileDomainEnvMappings() []worksmobileDomainEnvMapping {
{Key: "SAMAN_DOMAIN_ID", Label: "삼안"},
{Key: "HANMAC_DOMAIN_ID", Label: "한맥기술"},
{Key: "GPDTDC_DOMAIN_ID", Label: "총괄기획&기술개발센터"},
{Key: "HALLA_DOMAIN_ID", Label: "한라산업개발"},
{Key: "BARONGROUP_DOMAIN_ID", Label: "바론그룹"},
}
}
@@ -625,6 +640,10 @@ func isHanmacWorksmobileTenant(tenant domain.Tenant) bool {
return tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantMatchesAny(tenant, "hanmac", "한맥")
}
func isHallaWorksmobileTenant(tenant domain.Tenant) bool {
return tenantHasDomain(tenant, "hallasanup.com") || tenantMatchesAny(tenant, "halla", "hanlla", "한라산업개발")
}
func tenantHasDomain(tenant domain.Tenant, domainName string) bool {
domainName = strings.ToLower(strings.TrimSpace(domainName))
for _, d := range tenant.Domains {

View File

@@ -446,6 +446,7 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
tests := []struct {
@@ -468,6 +469,16 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
tenant: domain.Tenant{Slug: "gpdtdc", Name: "총괄기획&기술개발센터"},
want: 1003,
},
{
name: "halla",
tenant: domain.Tenant{Slug: "halla", Name: "한라산업개발", Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}}},
want: 1005,
},
{
name: "hanlla legacy slug",
tenant: domain.Tenant{Slug: "hanlla", Name: "한라산업개발"},
want: 1005,
},
{
name: "barongroup fallback",
tenant: domain.Tenant{Slug: "family-company", Name: "기타 가족사"},
@@ -484,6 +495,58 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
}
}
func TestResolveWorksmobileAccountDomainIDUsesHallaEmailDomain(t *testing.T) {
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
tenant := domain.Tenant{
Slug: "halla",
Name: "한라산업개발",
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
}
got, err := ResolveWorksmobileAccountDomainIDFromEmail("user@hallasanup.com", tenant, nil)
require.NoError(t, err)
require.Equal(t, int64(1005), got)
}
func TestWorksmobileDomainIDsFromEnvIncludesHallaBeforeFallback(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
got := WorksmobileDomainIDsFromEnv()
require.Equal(t, []int64{1001, 1002, 1003, 1005, 1004}, got)
require.Equal(t, "한라산업개발", WorksmobileDomainLabelForID(1005))
}
func TestBuildWorksmobileUserPayloadUsesHallaDomain(t *testing.T) {
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com")
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "main@hallasanup.com",
Name: "Halla User",
TenantID: &tenantID,
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "halla",
Name: "한라산업개발",
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
require.NoError(t, err)
require.Equal(t, int64(1005), payload.DomainID)
require.Equal(t, "main@hallasanup.com", payload.Email)
}
func TestBuildWorksmobileUserPayloadAddsHanmacEmployeeAlias(t *testing.T) {
t.Setenv("HANMAC_DOMAIN_ID", "1002")
tenantID := "33333333-3333-3333-3333-333333333333"

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"log/slog"
"sort"
"strings"
"time"
)
@@ -53,6 +54,7 @@ func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
if err != nil {
return err
}
jobs = sortWorksmobileReadyJobs(jobs)
for _, job := range jobs {
if err := w.processJob(ctx, job); err != nil {
slog.Warn("Worksmobile relay job failed", "jobID", job.ID, "resourceType", job.ResourceType, "resourceID", job.ResourceID, "error", err)
@@ -62,11 +64,15 @@ func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
}
func (w *WorksmobileRelayWorker) processJob(ctx context.Context, job domain.WorksmobileOutbox) error {
if err := w.repo.MarkProcessing(ctx, job.ID); err != nil {
claimed, err := w.repo.MarkProcessing(ctx, job.ID)
if err != nil {
return err
}
if !claimed {
return nil
}
err := w.dispatch(ctx, job)
err = w.dispatch(ctx, job)
if err != nil {
nextAttempt := time.Now().Add(worksmobileRetryDelay(job.RetryCount))
_ = w.repo.MarkFailed(ctx, job.ID, err.Error(), nextAttempt)
@@ -136,6 +142,91 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
}
}
func sortWorksmobileReadyJobs(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
sorted := append([]domain.WorksmobileOutbox(nil), jobs...)
depthByID := worksmobileOrgUnitDepths(sorted)
sort.SliceStable(sorted, func(i, j int) bool {
leftClass := worksmobileRelayOrderClass(sorted[i])
rightClass := worksmobileRelayOrderClass(sorted[j])
if leftClass != rightClass {
return leftClass < rightClass
}
leftDepth := depthByID[sorted[i].ID]
rightDepth := depthByID[sorted[j].ID]
if leftDepth != rightDepth {
return leftDepth < rightDepth
}
return sorted[i].CreatedAt.Before(sorted[j].CreatedAt)
})
return sorted
}
func worksmobileRelayOrderClass(job domain.WorksmobileOutbox) int {
if job.ResourceType == domain.WorksmobileResourceOrgUnit && job.Action == domain.WorksmobileActionUpsert {
return 0
}
if job.ResourceType == domain.WorksmobileResourceUser {
return 1
}
return 2
}
func worksmobileOrgUnitDepths(jobs []domain.WorksmobileOutbox) map[string]int {
type orgUnitJob struct {
jobID string
parentKey string
}
byExternalKey := map[string]orgUnitJob{}
for _, job := range jobs {
externalKey, parentKey := worksmobileOrgUnitExternalKeys(job)
if externalKey == "" {
continue
}
byExternalKey[externalKey] = orgUnitJob{jobID: job.ID, parentKey: parentKey}
}
depthByExternalKey := map[string]int{}
var depth func(externalKey string, seen map[string]bool) int
depth = func(externalKey string, seen map[string]bool) int {
if value, ok := depthByExternalKey[externalKey]; ok {
return value
}
job, ok := byExternalKey[externalKey]
if !ok || job.parentKey == "" || seen[externalKey] {
depthByExternalKey[externalKey] = 0
return 0
}
seen[externalKey] = true
value := depth(job.parentKey, seen) + 1
delete(seen, externalKey)
depthByExternalKey[externalKey] = value
return value
}
depthByJobID := map[string]int{}
for externalKey, job := range byExternalKey {
depthByJobID[job.jobID] = depth(externalKey, map[string]bool{})
}
return depthByJobID
}
func worksmobileOrgUnitExternalKeys(job domain.WorksmobileOutbox) (string, string) {
if job.ResourceType != domain.WorksmobileResourceOrgUnit || job.Action != domain.WorksmobileActionUpsert {
return "", ""
}
var payload WorksmobileOrgUnitPayload
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return "", ""
}
parentKey := strings.TrimSpace(payload.ParentOrgUnitID)
if strings.HasPrefix(parentKey, "externalKey:") {
parentKey = strings.TrimSpace(strings.TrimPrefix(parentKey, "externalKey:"))
} else {
parentKey = ""
}
return strings.TrimSpace(payload.OrgUnitExternalKey), parentKey
}
func worksmobileOutboxUserIdentifier(job domain.WorksmobileOutbox) string {
userID := stringValue(job.Payload["loginEmail"])
if userID == "" {

View File

@@ -14,6 +14,7 @@ import (
)
const HanmacFamilyTenantSlug = "hanmac-family"
const worksmobileExcludedConfigKey = "worksmobileExcluded"
type WorksmobileSyncer interface {
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
@@ -31,6 +32,7 @@ type WorksmobileAdminService interface {
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error)
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error)
ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
@@ -54,6 +56,10 @@ type WorksmobileBackfillDryRun struct {
UserCount int `json:"userCount"`
}
type WorksmobilePendingJobDeleteResult struct {
DeletedCount int `json:"deletedCount"`
}
type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
@@ -178,6 +184,21 @@ func worksmobileDirectoryAuthConfigured() bool {
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
}
func WorksmobileExcluded(config domain.JSONMap) bool {
rawValue, ok := config[worksmobileExcludedConfigKey]
if !ok {
return false
}
switch value := rawValue.(type) {
case bool:
return value
case string:
return strings.EqualFold(strings.TrimSpace(value), "true")
default:
return false
}
}
func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
for i := range jobs {
jobs[i].Payload = safeWorksmobileOutboxPayload(jobs[i].Payload)
@@ -394,6 +415,9 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target tenant is excluded from Worksmobile sync")
}
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
}
@@ -511,13 +535,16 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
if err != nil {
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
}
if domain.IsWorksDeprovisionUserStatus(user.Status) {
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
}
if !domain.IsWorksProvisionedUserStatus(user.Status) {
return nil, errors.New("target user status is excluded from Worksmobile sync")
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
*user,
*tenant,
@@ -582,6 +609,9 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
if err != nil {
return nil, err
@@ -722,6 +752,18 @@ func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID s
return s.outboxRepo.FindByID(ctx, jobID)
}
func (s *worksmobileSyncService) DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobilePendingJobDeleteResult{}, err
}
deleted, err := s.outboxRepo.DeletePendingByTenantRoot(ctx, root.ID)
if err != nil {
return WorksmobilePendingJobDeleteResult{}, err
}
return WorksmobilePendingJobDeleteResult{DeletedCount: int(deleted)}, nil
}
func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
root, ok, err := s.rootForTenant(ctx, tenant)
if err != nil || !ok {
@@ -732,6 +774,9 @@ func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Contex
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil
}
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
return nil
}
@@ -767,6 +812,9 @@ func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Contex
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil
}
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
return nil
}
@@ -795,6 +843,10 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[*user.TenantID]; !ok {
return nil
}
if domain.IsWorksDeprovisionUserStatus(user.Status) {
_, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID)
return err
@@ -802,7 +854,6 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
if !domain.IsWorksProvisionedUserStatus(user.Status) {
return nil
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
*tenant,
@@ -833,10 +884,18 @@ func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context,
if err != nil {
return err
}
_, ok, err := s.rootForTenant(ctx, *tenant)
root, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil || !ok {
return err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[*user.TenantID]; !ok {
return nil
}
_, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "")
return err
}
@@ -891,6 +950,9 @@ func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID strin
var visit func(id string)
visit = func(id string) {
for _, child := range byParent[id] {
if WorksmobileExcluded(child.Config) {
continue
}
result = append(result, child)
visit(child.ID)
}
@@ -1011,6 +1073,9 @@ func normalizeWorksmobileSlugLocalPart(value string) string {
}
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
if isWorksmobileDomainRootTenant(tenant) {
return false
}
if tenant.Type == domain.TenantTypeOrganization {
return true
}
@@ -1048,12 +1113,13 @@ func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[
func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
switch slug {
case "saman", "hanmac", "gpdtdc", "baron-group":
case "saman", "hanmac", "gpdtdc", "halla", "hanlla", "baron-group":
return true
}
if tenantHasDomain(tenant, "samaneng.com") ||
tenantHasDomain(tenant, "hanmaceng.co.kr") ||
tenantHasDomain(tenant, "baroncs.co.kr") ||
tenantHasDomain(tenant, "hallasanup.com") ||
tenantHasDomain(tenant, "brsw.kr") {
return true
}
@@ -1061,6 +1127,7 @@ func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
return name == "삼안" ||
name == "한맥기술" ||
name == "총괄기획&기술개발센터" ||
name == "한라산업개발" ||
name == "바론그룹"
}

View File

@@ -494,6 +494,70 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
}, orgPayload["requestSummary"])
}
func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
companyID := "saman-tenant"
newParentID := "new-parent-org"
childID := "child-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
Type: domain.TenantTypeCompanyGroup,
}
company := domain.Tenant{
ID: companyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
newParent := domain.Tenant{
ID: newParentID,
Slug: "planning",
Name: "총괄기획",
Type: domain.TenantTypeOrganization,
ParentID: &companyID,
}
child := domain.Tenant{
ID: childID,
Slug: "people-growth",
Name: "인재성장",
Type: domain.TenantTypeOrganization,
ParentID: &newParentID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{
rootID: root,
companyID: company,
newParentID: newParent,
childID: child,
},
list: []domain.Tenant{root, company, newParent, child},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
err := service.EnqueueTenantUpsertIfInScope(context.Background(), child)
require.NoError(t, err)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
require.Equal(t, childID, outboxRepo.created[0].ResourceID)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.True(t, ok)
require.Equal(t, childID, request.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+newParentID, request.ParentOrgUnitID)
require.Equal(t, "people-growth", outboxRepo.created[0].Payload["matchLocalPart"])
}
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
parentID := "root-tenant"
root := domain.Tenant{
@@ -1085,10 +1149,34 @@ func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testin
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceDeletesPendingJobsForTenantRoot(t *testing.T) {
rootID := "root-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
outboxRepo := &fakeWorksmobileOutboxRepo{deletedPendingCount: 2}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
result, err := service.DeletePendingJobs(context.Background(), rootID)
require.NoError(t, err)
require.Equal(t, 2, result.DeletedCount)
require.Equal(t, rootID, outboxRepo.deletedPendingTenantRootID)
}
func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
rootID := "root-tenant"
root := domain.Tenant{
@@ -1177,6 +1265,43 @@ func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *
wantDomainID: 1004,
wantEmail: "baron-planning@brsw.kr",
},
{
name: "halla",
company: domain.Tenant{
ID: "company-halla",
Slug: "halla",
Name: "한라산업개발",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
},
organization: domain.Tenant{
ID: "org-halla-planning",
Slug: "halla-planning",
Name: "한라 기획팀",
Type: domain.TenantTypeOrganization,
},
wantDomainID: 1005,
wantEmail: "halla-planning@hallasanup.com",
},
{
name: "hanlla legacy slug",
company: domain.Tenant{
ID: "company-hanlla",
Slug: "hanlla",
Name: "한라산업개발",
Type: domain.TenantTypeOrganization,
ParentID: &rootID,
},
organization: domain.Tenant{
ID: "org-hanlla-construction-sites",
Slug: "hanlla-construction-sites",
Name: "시공현장",
Type: domain.TenantTypeOrganization,
},
wantDomainID: 1005,
wantEmail: "hanlla-construction-sites@hallasanup.com",
},
}
for _, tt := range tests {
@@ -1467,6 +1592,181 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
}
func TestWorksmobileSyncServiceBackfillDryRunSkipsWorksmobileExcludedSubtree(t *testing.T) {
rootID := "root-tenant"
excludedCompanyID := "excluded-company"
excludedOrgID := "excluded-org"
includedCompanyID := "included-company"
includedOrgID := "included-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
excludedCompany := domain.Tenant{
ID: excludedCompanyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Config: domain.JSONMap{"worksmobileExcluded": true},
}
excludedOrg := domain.Tenant{
ID: excludedOrgID,
Slug: "excluded-team",
Name: "제외팀",
Type: domain.TenantTypeOrganization,
ParentID: &excludedCompanyID,
}
includedCompany := domain.Tenant{
ID: includedCompanyID,
Slug: "halla",
Name: "한라산업개발",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
includedOrg := domain.Tenant{
ID: includedOrgID,
Slug: "included-team",
Name: "연동팀",
Type: domain.TenantTypeOrganization,
ParentID: &includedCompanyID,
}
excludedUser := domain.User{
ID: "excluded-user",
Email: "excluded@samaneng.com",
Name: "Excluded User",
TenantID: &excludedOrgID,
Status: domain.UserStatusActive,
}
includedUser := domain.User{
ID: "included-user",
Email: "included@hallasanup.com",
Name: "Included User",
TenantID: &includedOrgID,
Status: domain.UserStatusActive,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{
rootID: root,
excludedCompanyID: excludedCompany,
excludedOrgID: excludedOrg,
includedCompanyID: includedCompany,
includedOrgID: includedOrg,
},
list: []domain.Tenant{root, excludedCompany, excludedOrg, includedCompany, includedOrg},
},
&fakeWorksmobileUserRepo{byTenant: []domain.User{excludedUser, includedUser}},
outboxRepo,
nil,
)
dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID)
require.NoError(t, err)
require.Equal(t, 1, dryRun.OrgUnitCount)
require.Equal(t, 1, dryRun.UserCount)
require.Len(t, outboxRepo.created, 1)
require.ElementsMatch(t, []string{includedOrgID}, outboxRepo.created[0].Payload["tenantIds"])
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
}
func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
rootID := "root-tenant"
excludedCompanyID := "excluded-company"
excludedOrgID := "excluded-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
excludedCompany := domain.Tenant{
ID: excludedCompanyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Config: domain.JSONMap{"worksmobileExcluded": true},
}
excludedOrg := domain.Tenant{
ID: excludedOrgID,
Slug: "excluded-team",
Name: "제외팀",
Type: domain.TenantTypeOrganization,
ParentID: &excludedCompanyID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg},
list: []domain.Tenant{root, excludedCompany, excludedOrg},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, excludedOrgID)
require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync")
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
rootID := "root-tenant"
excludedCompanyID := "excluded-company"
excludedOrgID := "excluded-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
excludedCompany := domain.Tenant{
ID: excludedCompanyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Config: domain.JSONMap{"worksmobileExcluded": true},
}
excludedOrg := domain.Tenant{
ID: excludedOrgID,
Slug: "excluded-team",
Name: "제외팀",
Type: domain.TenantTypeOrganization,
ParentID: &excludedCompanyID,
}
user := domain.User{
ID: "excluded-user",
Email: "excluded@samaneng.com",
Name: "Excluded User",
TenantID: &excludedOrgID,
Status: domain.UserStatusActive,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg},
list: []domain.Tenant{root, excludedCompany, excludedOrg},
},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{user.ID: user}},
outboxRepo,
nil,
)
require.NoError(t, service.EnqueueTenantUpsertIfInScope(context.Background(), excludedOrg))
require.NoError(t, service.EnqueueTenantDeleteIfInScope(context.Background(), excludedOrg))
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "")
require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync")
require.Empty(t, outboxRepo.created)
}
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
tenantID := "tenant-leaf"
user := domain.User{
@@ -1751,7 +2051,23 @@ func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
return f.byTenant, nil
if len(tenantIDs) == 0 {
return nil, nil
}
allowed := make(map[string]bool, len(tenantIDs))
for _, tenantID := range tenantIDs {
allowed[tenantID] = true
}
users := make([]domain.User, 0, len(f.byTenant))
for _, user := range f.byTenant {
if user.TenantID == nil {
continue
}
if allowed[*user.TenantID] {
users = append(users, user)
}
}
return users, nil
}
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {

View File

@@ -0,0 +1,148 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CommonAuditLog } from "../../audit";
import { AuditLogTable } from "./AuditLogTable";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
document.body.innerHTML = "";
});
function renderTable(props: Parameters<typeof AuditLogTable>[0]) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<AuditLogTable {...props} />);
});
return { container };
}
const logs: CommonAuditLog[] = [
{
event_id: "evt-1",
timestamp: "2026-05-28T06:07:18.000Z",
user_id: "user-1",
event_type: "CLIENT_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
device_id: "device-1",
details: JSON.stringify({
request_id: "req-1",
method: "POST",
path: "/api/v1/clients",
latency_ms: 120,
tenant_id: "tenant-1",
actor_id: "user-1",
action: "업데이트",
target_id: "client-a",
before: { status: "inactive" },
after: { status: "active" },
}),
},
];
describe("AuditLogTable", () => {
it("renders loading and empty states", () => {
const { container: loadingContainer } = renderTable({
logs: [],
t: (key, fallback) => fallback ?? key,
loading: true,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
expect(loadingContainer.textContent).toContain("Loading audit logs...");
const { container: emptyContainer } = renderTable({
logs: [],
t: (key, fallback) => fallback ?? key,
loading: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
expect(emptyContainer.textContent).toContain("No audit logs found.");
expect(emptyContainer.textContent).toContain("End of audit feed");
});
it("renders rows, expands details, copies fields, and loads more", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
const onLoadMore = vi.fn();
const { container } = renderTable({
logs,
t: (key, fallback, vars) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
loading: false,
hasNextPage: true,
isFetchingNextPage: false,
onLoadMore,
});
expect(container.textContent).toContain("user-1");
expect(container.textContent).toContain("업데이트");
expect(container.textContent).toContain("client-a");
expect(container.textContent).toContain("success");
const buttons = Array.from(container.querySelectorAll("button"));
const actorCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy User ID",
);
const targetCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy Client ID",
);
const expandButton = buttons.find(
(button) => !button.getAttribute("aria-label") && !button.textContent,
);
const loadMoreButton = buttons.find(
(button) => button.textContent === "Load more",
);
expect(actorCopyButton).toBeTruthy();
expect(targetCopyButton).toBeTruthy();
expect(expandButton).toBeTruthy();
expect(loadMoreButton).toBeTruthy();
await act(async () => {
actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith("user-1");
expect(writeText).toHaveBeenCalledWith("client-a");
expect(container.textContent).toContain("Request ID · req-1");
expect(container.textContent).toContain("Actor");
expect(container.textContent).toContain("Result");
await act(async () => {
loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
});

View File

@@ -13,7 +13,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
const skipWebServer =
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5176";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4174";
/**
* Read environment variables from file.
@@ -74,7 +74,7 @@ export default defineConfig({
? undefined
: {
command:
"VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 5176",
"VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 4174",
url: baseURL,
reuseExistingServer: false,
},

View File

@@ -0,0 +1,77 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}));
import LanguageSelector from "./LanguageSelector";
const roots: Root[] = [];
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, "", "/");
document.body.innerHTML = "";
});
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
});
function renderSelector() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<LanguageSelector />);
});
return container;
}
describe("LanguageSelector", () => {
it("prefers the locale stored in localStorage", () => {
window.localStorage.setItem("locale", "en");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
expect(select.value).toBe("en");
});
it("falls back to the path locale when storage is empty", () => {
window.history.replaceState({}, "", "/ko");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
expect(select.value).toBe("ko");
});
it("saves the selected locale and dispatches a development event", () => {
vi.stubEnv("MODE", "development");
const dispatchEvent = vi.spyOn(window, "dispatchEvent");
window.history.replaceState({}, "", "/ko");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
act(() => {
select.value = "en";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(window.localStorage.getItem("locale")).toBe("en");
expect(dispatchEvent).toHaveBeenCalled();
expect(select.value).toBe("en");
});
});

View File

@@ -0,0 +1,107 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CopyButton } from "./copy-button";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
delete (navigator as Navigator & { clipboard?: unknown }).clipboard;
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
document.body.innerHTML = "";
});
function renderCopyButton(value: string, onCopy = vi.fn()) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<CopyButton value={value} onCopy={onCopy} />);
});
return { container, onCopy };
}
describe("CopyButton", () => {
it("copies with the clipboard API when secure context is available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: true,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith("client-secret");
expect(onCopy).toHaveBeenCalledTimes(1);
});
it("falls back to execCommand when clipboard API is unavailable", async () => {
const execCommand = vi.fn(() => true);
Object.defineProperty(document, "execCommand", {
value: execCommand,
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(execCommand).toHaveBeenCalledWith("copy");
expect(onCopy).toHaveBeenCalledTimes(1);
});
it("keeps running when the fallback copy flow fails", async () => {
const execCommand = vi.fn(() => false);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
Object.defineProperty(document, "execCommand", {
value: execCommand,
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(execCommand).toHaveBeenCalledWith("copy");
expect(onCopy).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,212 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AuditLogsPage from "./AuditLogsPage";
const navigateMock = vi.fn();
const fetchMeMock = vi.fn();
const fetchDevAuditLogsMock = vi.fn();
let gateState = {
hasDeveloperAccess: true,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
isLoadingDeveloperAccessGate: false,
};
vi.mock("react-oidc-context", () => ({
useAuth: () => ({
isAuthenticated: true,
isLoading: false,
user: {
access_token: "access-token",
profile: {
role: "super_admin",
tenant_id: "tenant-1",
},
},
}),
}));
vi.mock("react-router-dom", () => ({
useNavigate: () => navigateMock,
}));
vi.mock("../developer-access/developerAccessGate", () => ({
useDeveloperAccessGate: () => gateState,
}));
vi.mock("../../lib/devApi", () => ({
fetchDevAuditLogs: (...args: unknown[]) => fetchDevAuditLogsMock(...args),
}));
vi.mock("../auth/authApi", () => ({
fetchMe: (...args: unknown[]) => fetchMeMock(...args),
}));
vi.mock("../../../../common/core/components/audit", () => ({
AuditLogTable: ({
logs,
onLoadMore,
}: {
logs: Array<{ event_id: string }>;
onLoadMore: () => void;
}) => (
<div>
<div>table:{logs.length}</div>
<button type="button" onClick={onLoadMore}>
Load more
</button>
</div>
),
}));
vi.mock("../../components/common/ForbiddenMessage", () => ({
ForbiddenMessage: ({ resourceToken }: { resourceToken: string }) => (
<div>Forbidden:{resourceToken}</div>
),
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
beforeEach(() => {
gateState = {
hasDeveloperAccess: true,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
isLoadingDeveloperAccessGate: false,
};
fetchMeMock.mockResolvedValue({
id: "user-1",
role: "super_admin",
});
fetchDevAuditLogsMock.mockResolvedValue({
items: [
{
event_id: "evt-1",
timestamp: "2026-05-28T06:07:18.000Z",
user_id: "user-1",
event_type: "CLIENT_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
details: JSON.stringify({
action: "업데이트",
target_id: "client-a",
}),
},
],
limit: 50,
});
});
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<AuditLogsPage />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("AuditLogsPage", () => {
it("shows the loading gate state", async () => {
gateState = {
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
isLoadingDeveloperAccessGate: true,
};
const container = await renderPage();
expect(container.textContent).toContain("로딩 중...");
});
it("renders the access request card when access is denied", async () => {
gateState = {
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: true,
isLoadingDeveloperAccessGate: false,
};
const container = await renderPage();
expect(container.textContent).toContain(
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
);
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes("개발자 권한 신청"),
);
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
it("exports the fetched logs as CSV", async () => {
const createObjectURL = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:csv");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockReturnValue();
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const container = await renderPage();
expect(container.textContent).toContain("table:1");
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent === "CSV 내보내기",
);
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(createObjectURL).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:csv");
});
it("renders the forbidden state on 403 errors", async () => {
fetchDevAuditLogsMock.mockRejectedValueOnce({
response: { status: 403 },
message: "Forbidden",
});
const container = await renderPage();
expect(container.textContent).toContain("Forbidden:audit");
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { fetchMe } from "./authApi";
const getMock = vi.fn();
vi.mock("../../lib/apiClient", () => ({
default: {
get: (...args: unknown[]) => getMock(...args),
},
}));
describe("fetchMe", () => {
it("returns the response payload from the API client", async () => {
getMock.mockResolvedValueOnce({ data: { id: "user-1", name: "Dev" } });
await expect(fetchMe()).resolves.toEqual({ id: "user-1", name: "Dev" });
expect(getMock).toHaveBeenCalledWith("/user/me");
});
});

View File

@@ -0,0 +1,280 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ClientsPage from "./ClientsPage";
const navigateMock = vi.fn();
const fetchClientsMock = vi.fn();
const fetchMeMock = vi.fn();
const fetchDeveloperRequestStatusMock = vi.fn();
const fetchMyTenantsMock = vi.fn();
const requestDeveloperAccessMock = vi.fn();
let authState = {
user: {
access_token: "access-token",
profile: {
role: "super_admin",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Dev Admin",
email: "dev@example.com",
phone: "010-0000-0000",
},
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("react-router-dom", async () => {
const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...actual,
useNavigate: () => navigateMock,
};
});
vi.mock("../../lib/devApi", () => ({
fetchClients: () => fetchClientsMock(),
fetchMe: () => fetchMeMock(),
fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(),
fetchMyTenants: () => fetchMyTenantsMock(),
requestDeveloperAccess: (...args: unknown[]) =>
requestDeveloperAccessMock(...args),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
beforeEach(() => {
authState = {
user: {
access_token: "access-token",
profile: {
role: "super_admin",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Dev Admin",
email: "dev@example.com",
phone: "010-0000-0000",
},
},
};
fetchClientsMock.mockResolvedValue({
items: [],
limit: 100,
offset: 0,
});
fetchMeMock.mockResolvedValue({
role: "super_admin",
name: "Dev Admin",
email: "dev@example.com",
phone: "010-0000-0000",
});
fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" });
fetchMyTenantsMock.mockResolvedValue([
{
id: "tenant-1",
name: "Hanmac",
slug: "hanmac",
type: "COMPANY",
parentId: null,
description: "",
status: "active",
memberCount: 10,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]);
});
function makeClients(count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `client-${index + 1}`,
name: `App ${index + 1}`,
type: index % 2 === 0 ? "private" : "pkce",
status: index % 2 === 0 ? "active" : "inactive",
createdAt: `2026-05-${String(index + 1).padStart(2, "0")}T00:00:00Z`,
redirectUris: [],
scopes: [],
metadata: {},
}));
}
async function setInputValue(input: HTMLInputElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ClientsPage />
</MemoryRouter>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("ClientsPage", () => {
it("expands the list and applies search filters", async () => {
fetchClientsMock.mockResolvedValue({
items: makeClients(6),
limit: 100,
offset: 0,
});
const container = await renderPage();
expect(container.textContent).toContain(
"총 6개의 애플리케이션이 등록되어 있습니다.",
);
expect(container.textContent).toContain("App 6");
expect(container.textContent).toContain("App 2");
expect(container.textContent).not.toContain("App 1");
const moreButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "더보기",
);
expect(moreButton).toBeTruthy();
await act(async () => {
moreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("App 6");
expect(container.textContent).toContain("접기");
const advancedButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent === "Advanced Filters");
expect(advancedButton).toBeTruthy();
await act(async () => {
advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const searchInput = Array.from(container.querySelectorAll("input")).find(
(input) =>
input
.getAttribute("placeholder")
?.includes("클라이언트 이름/ID로 검색"),
) as HTMLInputElement | undefined;
if (!searchInput) {
throw new Error("Expected search input to be rendered");
}
await act(async () => {
await setInputValue(searchInput, "missing-client");
});
expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다.");
const resetButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "초기화",
);
expect(resetButton).toBeTruthy();
await act(async () => {
resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await setInputValue(searchInput, "");
});
expect(container.textContent).toContain("App 1");
});
it("navigates to the developer request page from empty states", async () => {
authState = {
user: {
access_token: "access-token",
profile: {
role: "user",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
},
},
};
fetchClientsMock.mockResolvedValue({
items: [],
limit: 100,
offset: 0,
});
fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" });
fetchMeMock.mockResolvedValue({
role: "user",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
});
const container = await renderPage();
expect(container.textContent).toContain("개발자 등록 신청하기");
const requestButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "개발자 등록 신청하기",
);
expect(requestButton).toBeTruthy();
await act(async () => {
requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
});

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Filter, Info, Plus, Search, ShieldHalf, X } from "lucide-react";
import { Filter, Plus, Search, ShieldHalf, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom";
@@ -44,12 +44,8 @@ import {
import { Textarea } from "../../components/ui/textarea";
import {
type ClientSummary,
type DevAuditLog,
fetchDevUser,
fetchClients,
fetchDevAuditLogs,
fetchDeveloperRequestStatus,
fetchDevStats,
fetchMyTenants,
requestDeveloperAccess,
} from "../../lib/devApi";
@@ -59,196 +55,9 @@ import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi";
import { resolveClientCreateAccess } from "./clientCreateAccess";
import { ClientLogo } from "./components/ClientLogo";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditActor,
} from "../../../../common/core/audit";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
type RecentClientChange = {
eventId: string;
clientId: string;
clientName: string;
actorId: string;
actorName: string;
action: string;
actionLabel: string;
timestamp: string;
detailLabels: Array<{ label: string; value: string }>;
};
const recentClientChangesInitialCount = 5;
const recentClientChangesBatchSize = 5;
const recentClientActions = new Set([
"CREATE_CLIENT",
"UPDATE_CLIENT",
"UPDATE_CLIENT_STATUS",
"ROTATE_SECRET",
"ADD_RELATION",
"REMOVE_RELATION",
"DELETE_CLIENT",
]);
const recentChangeGuideItems = [
{
titleKey: "ui.dev.clients.recent_changes.guide.create",
titleFallback: "앱 생성",
descriptionKey: "msg.dev.clients.recent_changes.guide.create_desc",
descriptionFallback:
"새 애플리케이션이 등록되면 이름, 유형, 기본 상태와 함께 표시됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.settings",
titleFallback: "설정 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.settings_desc",
descriptionFallback:
"앱 이름, 스코프, 테넌트 접근 제한, 커스텀 클레임, 보안 설정, 로그아웃 URI, JWKS 변경이 포함됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.status",
titleFallback: "상태 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.status_desc",
descriptionFallback: "Active / Inactive 전환이 여기에 포함됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.relation",
titleFallback: "관계 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.relation_desc",
descriptionFallback: "관계 추가와 삭제가 함께 표시됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.secret",
titleFallback: "클라이언트 시크릿 재발급",
descriptionKey: "msg.dev.clients.recent_changes.guide.secret_desc",
descriptionFallback: "시크릿 재발급 이력이 보입니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.delete",
titleFallback: "앱 삭제",
descriptionKey: "msg.dev.clients.recent_changes.guide.delete_desc",
descriptionFallback: "앱 삭제도 최근 변경 이력에 포함됩니다.",
},
] as const;
const recentClientFieldLabels: Record<string, string> = {
name: "이름",
type: "유형",
status: "상태",
scopes: "스코프",
tenant_access_restricted: "테넌트 접근 제한",
allowed_tenants: "허용 테넌트",
id_token_claims: "커스텀 클레임",
token_endpoint_auth_method: "인증 방식",
jwks_uri: "JWKS URI",
backchannel_logout_uri: "Backchannel Logout URI",
backchannel_logout_session_required: "세션 필수",
headless_login_enabled: "헤드리스 로그인",
headless_token_endpoint_auth_method: "헤드리스 인증 방식",
headless_jwks_uri: "헤드리스 JWKS URI",
redirect_uri_count: "Redirect URI 수",
scope_count: "Scope 수",
relation: "관계",
subject: "대상",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getRecentClientActionLabel(action: string) {
switch (action) {
case "CREATE_CLIENT":
return "클라이언트 생성";
case "UPDATE_CLIENT":
return "설정 변경";
case "UPDATE_CLIENT_STATUS":
return "상태 변경";
case "ROTATE_SECRET":
return "클라이언트 시크릿 재발급";
case "ADD_RELATION":
return "관계 추가";
case "REMOVE_RELATION":
return "관계 삭제";
case "DELETE_CLIENT":
return "클라이언트 삭제";
default:
return action;
}
}
function buildRecentClientChangeDetails(
action: string,
details: ReturnType<typeof parseAuditDetails>,
) {
const before = isRecord(details.before) ? details.before : {};
const after = isRecord(details.after) ? details.after : {};
if (action === "ROTATE_SECRET") {
return [{ label: "클라이언트 시크릿", value: "재발급" }];
}
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
const source = action === "ADD_RELATION" ? after : before;
return [
...(source.relation
? [{ label: "관계", value: formatAuditValue(source.relation) }]
: []),
...(source.subject
? [{ label: "대상", value: formatAuditValue(source.subject) }]
: []),
];
}
const keys = Array.from(
new Set([...Object.keys(before), ...Object.keys(after)]),
);
const changes = keys
.map((key) => {
const beforeValue = before[key];
const afterValue = after[key];
if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) {
return null;
}
}
const label = recentClientFieldLabels[key] ?? key;
if (action === "CREATE_CLIENT") {
if (afterValue === undefined) {
return null;
}
return { label, value: formatAuditValue(afterValue) };
}
if (action === "DELETE_CLIENT") {
if (beforeValue === undefined) {
return null;
}
return { label, value: formatAuditValue(beforeValue) };
}
if (beforeValue === undefined && afterValue === undefined) {
return null;
}
if (beforeValue === undefined) {
return { label, value: formatAuditValue(afterValue) };
}
if (afterValue === undefined) {
return { label, value: formatAuditValue(beforeValue) };
}
return {
label,
value: `${formatAuditValue(beforeValue)}${formatAuditValue(afterValue)}`,
};
})
.filter((item): item is { label: string; value: string } => Boolean(item));
return changes.slice(0, 3);
}
const clientListPreviewCount = 5;
function ClientsPage() {
const navigate = useNavigate();
@@ -269,12 +78,6 @@ function ClientsPage() {
enabled: hasAccessToken,
});
const { data: statsData, isLoading: isLoadingStats } = useQuery({
queryKey: ["dev-stats"],
queryFn: fetchDevStats,
enabled: hasAccessToken,
});
const { data: me, isLoading: isLoadingMe } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
@@ -314,10 +117,7 @@ function ClientsPage() {
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [isRecentChangesGuideOpen, setIsRecentChangesGuideOpen] =
useState(false);
const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] =
useState(recentClientChangesInitialCount);
const [isClientListExpanded, setIsClientListExpanded] = useState(false);
const [sortConfig, setSortConfig] =
useState<SortConfig<ClientSortKey> | null>({
key: "createdAt",
@@ -325,61 +125,6 @@ function ClientsPage() {
});
const clients = data?.items || [];
const visibleClientIds = useMemo(
() => clients.map((client) => client.id).filter(Boolean),
[clients],
);
const { data: recentAuditData, isLoading: isLoadingRecentAudit } = useQuery({
queryKey: ["dev-audit-logs", "clients-recent", visibleClientIds.join("|")],
queryFn: async () => {
const globalLogs = await fetchDevAuditLogs(50);
if (globalLogs.items.length > 0 || profileRole === "super_admin") {
return globalLogs;
}
if (visibleClientIds.length === 0) {
return globalLogs;
}
const perClientLogs = await Promise.all(
visibleClientIds.slice(0, 20).map(async (clientId) => {
try {
const result = await fetchDevAuditLogs(5, undefined, {
client_id: clientId,
});
return result.items;
} catch {
return [];
}
}),
);
const merged = perClientLogs
.flat()
.filter(
(item, index, self) =>
self.findIndex(
(candidate) => candidate.event_id === item.event_id,
) === index,
)
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
)
.slice(0, 50);
return {
items: merged,
limit: 50,
cursor: globalLogs.cursor,
next_cursor: globalLogs.next_cursor,
};
},
enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole),
retry: false,
});
const clientSortResolvers = useMemo<
SortResolverMap<ClientSummary, ClientSortKey>
@@ -420,11 +165,16 @@ function ClientsPage() {
typeFilter,
]);
const totalClients = statsData?.total_clients ?? clients.length;
const activeSessions = statsData?.active_sessions ?? 0;
const authFailures = statsData?.auth_failures_24h ?? 0;
const hasFilterResult = filteredClients.length > 0;
const isFilteredOut = clients.length > 0 && !hasFilterResult;
const visibleClients = useMemo(() => {
if (isClientListExpanded) {
return filteredClients;
}
return filteredClients.slice(0, clientListPreviewCount);
}, [filteredClients, isClientListExpanded]);
const canToggleClientList = filteredClients.length > clientListPreviewCount;
const currentTenant = tenants?.find(
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
);
@@ -438,145 +188,8 @@ function ClientsPage() {
"";
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
type StatTone = "up" | "down" | "stable";
type StatItem = {
labelKey: string;
labelFallback: string;
value: string;
deltaKey: string;
deltaFallback: string;
tone: StatTone;
};
const stats: StatItem[] = [
{
labelKey: "ui.dev.clients.stats.total",
labelFallback: "Total Applications",
value: totalClients.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.active_sessions",
labelFallback: "Active Sessions",
value: activeSessions.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.auth_failures",
labelFallback: "Auth Failures (24h)",
value: authFailures.toString(),
deltaKey:
authFailures > 0
? "ui.dev.clients.stats.alert"
: "ui.dev.clients.stats.stable",
deltaFallback: authFailures > 0 ? "Check Logs" : "Stable",
tone: authFailures > 0 ? ("down" as const) : ("stable" as const),
},
];
const recentClientChanges = useMemo<RecentClientChange[]>(() => {
const clientNameById = new Map(
clients.map((client) => [client.id, client.name || client.id]),
);
return (recentAuditData?.items || [])
.map((item: DevAuditLog) => {
const details = parseAuditDetails(item.details);
const action = details.action || "";
const clientId = String(details.target_id || "");
if (!recentClientActions.has(action) || !clientId) {
return null;
}
return {
eventId: item.event_id,
clientId,
clientName: clientNameById.get(clientId) || clientId,
actorId: resolveAuditActor(item, details),
actorName: "",
action,
actionLabel: getRecentClientActionLabel(action),
timestamp: item.timestamp,
detailLabels: buildRecentClientChangeDetails(action, details),
};
})
.filter((item): item is RecentClientChange => Boolean(item))
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
);
}, [clients, recentAuditData?.items]);
const recentClientActorIds = useMemo(() => {
return Array.from(
new Set(
recentClientChanges
.map((item) => item.actorId.trim())
.filter((actorId) => actorId && actorId !== "-"),
),
);
}, [recentClientChanges]);
const { data: recentClientActors } = useQuery({
queryKey: ["recent-client-actors", recentClientActorIds],
queryFn: async () => {
const entries = await Promise.all(
recentClientActorIds.map(async (actorId) => {
try {
const user = await fetchDevUser(actorId);
return [actorId, user.name || actorId] as const;
} catch {
return [actorId, actorId] as const;
}
}),
);
return Object.fromEntries(entries);
},
enabled: recentClientActorIds.length > 0,
});
const recentClientChangesWithActors = useMemo(() => {
return recentClientChanges.map((item) => ({
...item,
actorName: recentClientActors?.[item.actorId] || item.actorId,
}));
}, [recentClientActors, recentClientChanges]);
const recentChangedClientCount = useMemo(() => {
return new Set(recentClientChangesWithActors.map((item) => item.clientId))
.size;
}, [recentClientChangesWithActors]);
const visibleRecentClientChanges = useMemo(() => {
return recentClientChangesWithActors.slice(
0,
visibleRecentClientChangesCount,
);
}, [recentClientChangesWithActors, visibleRecentClientChangesCount]);
const hasMoreRecentClientChanges =
recentClientChangesWithActors.length > visibleRecentClientChanges.length;
useEffect(() => {
if (
visibleRecentClientChangesCount > recentClientChangesWithActors.length
) {
setVisibleRecentClientChangesCount(
Math.max(
recentClientChangesInitialCount,
recentClientChangesWithActors.length,
),
);
}
}, [recentClientChangesWithActors.length, visibleRecentClientChangesCount]);
const isLoading =
isLoadingClients ||
isLoadingStats ||
isLoadingRecentAudit ||
isLoadingRequest ||
(hasAccessToken && !profileRole && isLoadingMe);
@@ -621,7 +234,7 @@ function ClientsPage() {
canCreateClient ? (
<Button
size="sm"
className="shadow-lg shadow-primary/30"
className="mt-1 shadow-lg shadow-primary/30"
onClick={() => navigate("/clients/new")}
>
<Plus className="h-4 w-4" />
@@ -679,7 +292,19 @@ function ClientsPage() {
/>
<Card className="glass-panel">
<CardHeader className="pb-4 pt-6">
<CardHeader className="space-y-4 pb-4 pt-6">
<div>
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.showing",
"총 {{shown}}개의 애플리케이션이 등록되어 있습니다.",
{ shown: clients.length },
)}
</CardDescription>
</div>
<SearchFilterBar
primary={
<div className="relative flex-1">
@@ -696,34 +321,21 @@ function ClientsPage() {
</div>
}
actions={
<>
<Button
variant="ghost"
size="sm"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)}
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
>
<Filter className="h-4 w-4" />
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
<div className="hidden items-center gap-2 md:flex">
<Badge variant="muted">
{t(
"ui.dev.clients.badge.tenant_selected",
"테넌트: 선택됨",
)}
</Badge>
<Badge variant="success">
{t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
</Badge>
</div>
</>
<Button
variant="ghost"
size="sm"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)}
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
>
<Filter className="h-4 w-4" />
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
}
advancedOpen={isAdvancedFilterOpen}
advanced={
@@ -783,54 +395,6 @@ function ClientsPage() {
}
/>
</CardHeader>
<CardContent className="pt-0">
<div className="grid gap-4 md:grid-cols-3">
{stats.map((item) => (
<Card key={item.labelKey} className="border border-border/60">
<CardHeader className="pb-2">
<CardDescription>
{t(item.labelKey, item.labelFallback)}
</CardDescription>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-3xl font-bold">{item.value}</span>
<Badge
variant={
item.tone === "up"
? "success"
: item.tone === "down"
? "warning"
: "muted"
}
className={cn(
"px-2",
item.tone === "stable" && "bg-muted/40 text-foreground",
)}
>
{t(item.deltaKey, item.deltaFallback)}
</Badge>
</div>
</CardHeader>
</Card>
))}
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.showing",
"총 {{shown}}개의 애플리케이션이 등록되어 있습니다.",
{ shown: totalClients },
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
@@ -954,7 +518,7 @@ function ClientsPage() {
</TableCell>
</TableRow>
)}
{filteredClients.map((client) => (
{visibleClients.map((client) => (
<TableRow key={client.id}>
<TableCell>
<Link
@@ -968,10 +532,12 @@ function ClientsPage() {
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.tenant_scoped",
"Tenant-scoped",
)}
<span aria-hidden="true">
{t(
"ui.dev.clients.tenant_scoped",
"Tenant-scoped",
)}
</span>
</p>
</div>
</Link>
@@ -1039,161 +605,25 @@ function ClientsPage() {
</Table>
</div>
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-4">
<div>
<div className="flex items-center gap-1.5">
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")}
</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-1 h-8 w-8 translate-y-px text-muted-foreground hover:text-primary"
aria-label={t(
"ui.dev.clients.recent_changes.guide_button",
"최근 변경 항목 안내 열기",
)}
aria-expanded={isRecentChangesGuideOpen}
onClick={() =>
setIsRecentChangesGuideOpen((current) => !current)
}
>
<Info className="h-4 w-4" />
</Button>
</div>
<CardDescription>
{t(
"msg.dev.clients.recent_changes.description",
"총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.",
{ count: recentChangedClientCount },
)}
</CardDescription>
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
{t(
"msg.dev.clients.recent_changes.permission_note",
"'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.",
)}
</p>
{isRecentChangesGuideOpen && (
<div className="mt-3 rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-sm font-semibold text-foreground">
{t(
"ui.dev.clients.recent_changes.guide_title",
"최근 변경 항목 안내",
)}
</p>
<div className="mt-3 space-y-3">
{recentChangeGuideItems.map((item) => (
<div key={item.titleKey} className="space-y-1">
<p className="text-sm font-medium text-foreground">
{t(item.titleKey, item.titleFallback)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{t(item.descriptionKey, item.descriptionFallback)}
</p>
</div>
))}
<p className="text-xs leading-5 text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.guide.audit_only",
"동의 철회는 최근 변경된 앱 카드에 포함하지 않고, 감사 로그에서 확인합니다.",
)}
</p>
</div>
</div>
)}
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/audit-logs">
{t("ui.common.audit.title", "Audit Logs")}
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3 pt-0">
{visibleRecentClientChanges.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5 text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.empty",
"최근 변경 로그가 아직 없습니다.",
)}
</div>
) : (
visibleRecentClientChanges.map((item) => {
const { date, time } = formatAuditDateParts(item.timestamp);
return (
<div
key={item.eventId}
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card/40 p-4 md:flex-row md:items-start md:justify-between"
>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/clients/${item.clientId}`}
className="font-semibold transition-colors hover:text-primary"
>
{item.clientName}
</Link>
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{item.clientId}
</code>
<span className="font-semibold">{item.actorName}</span>
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{item.actorId}
</code>
<Badge variant="muted">{item.actionLabel}</Badge>
</div>
<div className="flex flex-wrap gap-2">
{item.detailLabels.length > 0 ? (
item.detailLabels.map((detail) => (
<Badge
key={`${item.eventId}-${detail.label}`}
variant="outline"
>
{detail.label}: {detail.value}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.no_detail",
"변경 항목을 확인할 수 없습니다.",
)}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{date} {time}
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${item.clientId}`}>
{t("ui.common.view", "View")}
</Link>
</Button>
</div>
);
})
)}
{hasMoreRecentClientChanges ? (
<div className="pt-2 text-center">
{canToggleClientList ? (
<div className="mt-4 flex justify-center">
<Button
type="button"
variant="outline"
onClick={() =>
setVisibleRecentClientChangesCount((current) =>
Math.min(
current + recentClientChangesBatchSize,
recentClientChangesWithActors.length,
),
)
size="sm"
aria-label={
isClientListExpanded
? t(
"ui.dev.clients.list.collapse_aria",
"연동 앱 목록 접기",
)
: t("ui.dev.clients.list.more_aria", "연동 앱 목록 더보기")
}
onClick={() => setIsClientListExpanded((current) => !current)}
>
{t("ui.common.load_more", "더보기")}
{isClientListExpanded
? t("ui.common.collapse", "접기")
: t("ui.common.load_more", "더보기")}
</Button>
</div>
) : null}

View File

@@ -0,0 +1,65 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import type { ReactNode, ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ClientLogo } from "./ClientLogo";
vi.mock("../../../components/ui/avatar", () => ({
Avatar: ({ children }: { children: ReactNode }) => (
<div data-testid="avatar">{children}</div>
),
AvatarImage: (props: ComponentProps<"img">) => <img alt="" {...props} />,
AvatarFallback: ({ children }: { children: ReactNode }) => (
<div data-testid="fallback">{children}</div>
),
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
document.body.innerHTML = "";
});
function renderLogo(client: Parameters<typeof ClientLogo>[0]["client"]) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<ClientLogo client={client} />);
});
return container;
}
describe("ClientLogo", () => {
it("renders the fallback icon when no logo url exists", () => {
const container = renderLogo({
name: "",
type: "private",
metadata: {},
});
expect(container.querySelectorAll("svg").length).toBeGreaterThan(0);
});
it("uses the logo image when a trimmed url is provided", () => {
const container = renderLogo({
name: "Gitea",
type: "pkce",
metadata: { logo_url: " https://example.com/logo.png " },
});
const image = container.querySelector("img");
expect(image).not.toBeNull();
expect(container.querySelector("[data-testid='fallback']")).not.toBeNull();
expect(image?.getAttribute("alt")).toContain("Gitea");
expect(image?.getAttribute("src")).toBe("https://example.com/logo.png");
});
});

View File

@@ -0,0 +1,179 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ClientFederationPage } from "./ClientFederationPage";
let params: { id?: string } = { id: "client-a" };
const listIdpConfigsMock = vi.fn();
const createIdpConfigMock = vi.fn();
vi.mock("react-router-dom", async () => {
const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...actual,
useParams: () => params,
};
});
vi.mock("../../../lib/devApi", () => ({
listIdpConfigsForClient: (clientId: string) => listIdpConfigsMock(clientId),
createIdpConfigForClient: (payload: unknown) => createIdpConfigMock(payload),
}));
vi.mock("../../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
beforeEach(() => {
params = { id: "client-a" };
listIdpConfigsMock.mockResolvedValue([
{
id: "idp-1",
client_id: "client-a",
provider_type: "oidc",
display_name: "Workspace OIDC",
status: "active",
issuer_url: "https://accounts.example",
oidc_client_id: "oidc-client",
scopes: "openid email profile",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]);
createIdpConfigMock.mockResolvedValue({
id: "idp-2",
client_id: "client-a",
provider_type: "oidc",
display_name: "New Provider",
status: "active",
createdAt: "2026-05-02T00:00:00Z",
updatedAt: "2026-05-02T00:00:00Z",
});
});
async function setInputValue(input: HTMLInputElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ClientFederationPage />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("ClientFederationPage", () => {
it("shows a missing client id message when no route param exists", async () => {
params = {};
const container = await renderPage();
expect(container.textContent).toContain("Client ID is missing");
});
it("opens the create modal and submits a new IdP config", async () => {
const container = await renderPage();
expect(container.textContent).toContain("Workspace OIDC");
const addButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Add Provider",
);
expect(addButton).toBeTruthy();
await act(async () => {
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Add Identity Provider");
const displayName = container.querySelector(
'input[name="display_name"]',
) as HTMLInputElement | null;
const issuerUrl = container.querySelector(
'input[name="issuer_url"]',
) as HTMLInputElement | null;
const clientId = container.querySelector(
'input[name="oidc_client_id"]',
) as HTMLInputElement | null;
const clientSecret = container.querySelector(
'input[name="oidc_client_secret"]',
) as HTMLInputElement | null;
if (!displayName || !issuerUrl || !clientId || !clientSecret) {
throw new Error("Expected federation form inputs to be rendered");
}
await act(async () => {
await setInputValue(displayName, "New Provider");
await setInputValue(issuerUrl, "https://login.example");
await setInputValue(clientId, "client-oidc");
await setInputValue(clientSecret, "secret-value");
});
const submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Save Configuration",
);
expect(submitButton).toBeTruthy();
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(createIdpConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
client_id: "client-a",
display_name: "New Provider",
issuer_url: "https://login.example",
oidc_client_id: "client-oidc",
oidc_client_secret: "secret-value",
}),
);
});
});

View File

@@ -0,0 +1,123 @@
import { act } from "../../../../common/node_modules/react-dom/test-utils";
import { createRoot, type Root } from "../../../../common/node_modules/react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CommonAuditLog } from "../../../../common/core/audit";
import { AuditLogTable } from "../../../../common/core/components/audit";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
document.body.innerHTML = "";
});
function renderTable(props: Parameters<typeof AuditLogTable>[0]) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<AuditLogTable {...props} />);
});
return { container };
}
const logs: CommonAuditLog[] = [
{
event_id: "evt-1",
timestamp: "2026-05-28T06:07:18.000Z",
user_id: "user-1",
event_type: "CLIENT_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
device_id: "device-1",
details: JSON.stringify({
request_id: "req-1",
method: "POST",
path: "/api/v1/clients",
latency_ms: 120,
tenant_id: "tenant-1",
actor_id: "user-1",
action: "업데이트",
target_id: "client-a",
before: { status: "inactive" },
after: { status: "active" },
}),
},
];
describe("AuditLogTable", () => {
it("renders rows, expands details, copies fields, and loads more", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
const onLoadMore = vi.fn();
const { container } = renderTable({
logs,
t: (key, fallback, vars) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
loading: false,
hasNextPage: true,
isFetchingNextPage: false,
onLoadMore,
});
expect(container.textContent).toContain("user-1");
expect(container.textContent).toContain("업데이트");
expect(container.textContent).toContain("client-a");
expect(container.textContent).toContain("success");
const buttons = Array.from(container.querySelectorAll("button"));
const actorCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy User ID",
);
const targetCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy Client ID",
);
const expandButton = buttons.find(
(button) => !button.getAttribute("aria-label") && !button.textContent,
);
const loadMoreButton = buttons.find(
(button) => button.textContent === "Load more",
);
expect(actorCopyButton).toBeTruthy();
expect(targetCopyButton).toBeTruthy();
expect(expandButton).toBeTruthy();
expect(loadMoreButton).toBeTruthy();
await act(async () => {
actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith("user-1");
expect(writeText).toHaveBeenCalledWith("client-a");
expect(container.textContent).toContain("Request ID · req-1");
expect(container.textContent).toContain("Actor");
expect(container.textContent).toContain("Result");
await act(async () => {
loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../../../common/core/audit";
describe("common audit helpers", () => {
it("parses audit details and falls back on invalid payloads", () => {
expect(parseAuditDetails()).toEqual({});
expect(parseAuditDetails("not-json")).toEqual({});
expect(parseAuditDetails('{"action":"ADD_RELATION"}')).toEqual({
action: "ADD_RELATION",
});
});
it("formats audit values and dates", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(formatAuditValue(null)).toBe("-");
expect(formatAuditValue("hello")).toBe("hello");
expect(formatAuditValue({ a: 1 })).toBe('{"a":1}');
expect(formatAuditValue(circular)).toBe("[object Object]");
expect(formatAuditDateParts("")).toEqual({ date: "-", time: "-" });
expect(formatAuditDateParts("invalid")).toEqual({
date: "invalid",
time: "-",
});
const parsed = formatAuditDateParts("2026-05-27T07:43:39.000Z");
expect(parsed.date).toBe("2026-05-27");
expect(parsed.time).not.toBe("-");
});
it("resolves audit actor, action, and target consistently", () => {
expect(
resolveAuditActor(
{ user_id: "actor-1" },
{ actor_id: "actor-2" },
),
).toBe("actor-1");
expect(
resolveAuditActor({ user_id: "" }, { actor_id: "actor-2" }),
).toBe("actor-2");
expect(resolveAuditActor({ user_id: "" }, {})).toBe("-");
expect(
resolveAuditAction(
{ event_type: "UPDATE_CLIENT" },
{ action: "ADD_RELATION" },
),
).toBe("ADD_RELATION");
expect(
resolveAuditAction(
{ event_type: "UPDATE_CLIENT" },
{ method: "POST", path: "/dev/clients" },
),
).toBe("POST /dev/clients");
expect(resolveAuditAction({ event_type: "UPDATE_CLIENT" }, {})).toBe(
"UPDATE_CLIENT",
);
expect(resolveAuditTarget({ target: "target-1" })).toBe("target-1");
expect(resolveAuditTarget({ target_id: "target-2" })).toBe("target-2");
expect(resolveAuditTarget({})).toBe("-");
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_OIDC_REDIRECT_PATH,
DEFAULT_OIDC_SCOPE,
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
shouldStartLoginRedirect,
} from "../../../../common/core/auth";
describe("common auth helpers", () => {
it("builds the runtime OIDC config with sensible defaults", () => {
const config = buildCommonOidcRuntimeConfig({
authority: "https://issuer.example.com",
clientId: "client-1",
userStore: { kind: "store" },
});
expect(config).toEqual({
authority: "https://issuer.example.com",
client_id: "client-1",
redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`,
response_type: "code",
scope: DEFAULT_OIDC_SCOPE,
post_logout_redirect_uri: window.location.origin,
popup_redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`,
userStore: { kind: "store" },
automaticSilentRenew: false,
});
});
it("copies user manager config and fills missing string fields", () => {
expect(
buildCommonUserManagerSettings({
authority: "https://issuer.example.com",
}),
).toEqual({
authority: "https://issuer.example.com",
client_id: "",
redirect_uri: "",
});
});
it("decides when to start login redirects", () => {
expect(
shouldStartLoginRedirect({
pathname: "/clients",
isRedirecting: false,
}),
).toBe(true);
expect(
shouldStartLoginRedirect({
pathname: "/login",
isRedirecting: false,
}),
).toBe(false);
expect(
shouldStartLoginRedirect({
pathname: `${DEFAULT_OIDC_REDIRECT_PATH}/callback`,
isRedirecting: false,
}),
).toBe(false);
expect(
shouldStartLoginRedirect({
pathname: "/clients",
isRedirecting: true,
}),
).toBe(false);
});
});

View File

@@ -13,6 +13,11 @@ import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
import ProfilePage from "../profile/ProfilePage";
import {
approveDeveloperRequest,
cancelDeveloperRequestApproval,
rejectDeveloperRequest,
} from "../../lib/devApi";
const authProfile = {
sub: "user-1",
@@ -282,6 +287,19 @@ vi.mock("../../lib/devApi", () => ({
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: 2,
userId: "user-4",
tenantId: "tenant-1",
name: "Approved Requester",
organization: "Hanmac",
email: "approved@example.com",
reason: "Need elevated access",
status: "approved",
adminNotes: "Reviewed and approved",
createdAt: "2026-05-02T00:00:00Z",
updatedAt: "2026-05-02T00:00:00Z",
},
]),
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
@@ -348,6 +366,16 @@ async function renderPage(
return container;
}
async function setInputValue(input: HTMLInputElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("devfront coverage smoke pages", () => {
it("renders overview, client list, audit, developer request, and profile pages", async () => {
const overview = await renderPage(<GlobalOverviewPage />);
@@ -397,4 +425,65 @@ describe("devfront coverage smoke pages", () => {
});
expect(relations.textContent).toContain("Dev Admin");
});
it("covers developer request actions", async () => {
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => undefined);
const requests = await renderPage(<DeveloperRequestPage />);
expect(requests.textContent).toContain("Requester");
expect(requests.textContent).toContain("Approved Requester");
const pendingNote = Array.from(
requests.querySelectorAll("input"),
).find((input) => input.getAttribute("placeholder") === "메모 입력 (선택)...") as HTMLInputElement | undefined;
const cancelNote = Array.from(
requests.querySelectorAll("input"),
).find(
(input) => input.getAttribute("placeholder") === "승인 취소 사유 입력...",
) as HTMLInputElement | undefined;
expect(pendingNote).toBeTruthy();
expect(cancelNote).toBeTruthy();
await act(async () => {
await setInputValue(pendingNote!, "");
});
const buttons = Array.from(requests.querySelectorAll("button"));
const rejectButton = buttons.find((button) => button.textContent === "반려");
const approveButton = buttons.find((button) => button.textContent === "승인");
const cancelButton = buttons.find(
(button) => button.textContent === "승인 취소",
);
expect(rejectButton).toBeTruthy();
expect(approveButton).toBeTruthy();
expect(cancelButton).toBeTruthy();
await act(async () => {
rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(alertSpy).toHaveBeenCalledWith("반려 사유를 입력해주세요.");
await act(async () => {
await setInputValue(pendingNote!, "Need more context");
approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
await setInputValue(cancelNote!, "Approve needs revision");
cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(approveDeveloperRequest).toHaveBeenCalledWith(1, "Need more context");
expect(rejectDeveloperRequest).not.toHaveBeenCalled();
expect(cancelDeveloperRequestApproval).toHaveBeenCalledWith(
2,
"Approve needs revision",
);
alertSpy.mockRestore();
});
});

View File

@@ -0,0 +1,188 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import DeveloperRequestPage from "./DeveloperRequestPage";
const fetchDeveloperRequestsMock = vi.fn();
const fetchMyTenantsMock = vi.fn();
const fetchMeMock = vi.fn();
const requestDeveloperAccessMock = vi.fn();
let authState = {
user: {
access_token: "access-token",
profile: {
role: "user",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
},
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../auth/authApi", () => ({
fetchMe: () => fetchMeMock(),
}));
vi.mock("../../lib/devApi", () => ({
fetchDeveloperRequests: () => fetchDeveloperRequestsMock(),
fetchMyTenants: () => fetchMyTenantsMock(),
requestDeveloperAccess: (...args: unknown[]) =>
requestDeveloperAccessMock(...args),
approveDeveloperRequest: vi.fn(),
rejectDeveloperRequest: vi.fn(),
cancelDeveloperRequestApproval: vi.fn(),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
beforeEach(() => {
authState = {
user: {
access_token: "access-token",
profile: {
role: "user",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
},
},
};
fetchDeveloperRequestsMock.mockResolvedValue([]);
fetchMyTenantsMock.mockResolvedValue([
{
id: "tenant-1",
name: "Hanmac",
slug: "hanmac",
type: "COMPANY",
parentId: null,
description: "",
status: "active",
memberCount: 10,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]);
fetchMeMock.mockResolvedValue({
id: "user-1",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
role: "user",
});
requestDeveloperAccessMock.mockResolvedValue({ status: "pending" });
});
async function setTextAreaValue(input: HTMLTextAreaElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<DeveloperRequestPage />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("DeveloperRequestPage", () => {
it("opens the request modal and submits a request", async () => {
const container = await renderPage();
expect(container.textContent).toContain("신규 신청하기");
const actionButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("신규 신청하기"),
);
expect(actionButton).toBeTruthy();
await act(async () => {
actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("개발자 등록 신청");
const reasonField = container.querySelector(
"textarea",
) as HTMLTextAreaElement | null;
if (!reasonField) {
throw new Error("Expected reason textarea to be rendered");
}
await act(async () => {
await setTextAreaValue(reasonField, "Need RP access");
});
const submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "신청하기",
);
expect(submitButton).toBeTruthy();
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(requestDeveloperAccessMock).toHaveBeenCalled();
expect(requestDeveloperAccessMock.mock.calls[0]?.[0]).toEqual({
name: "Requester",
organization: "Hanmac",
reason: "Need RP access",
tenantId: "tenant-1",
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
import {
buildRecentClientChangeDetails,
buildRecentClientChanges,
getRecentClientActionLabel,
} from "./recentClientChanges";
function makeClient(id: string, name = id): ClientSummary {
return {
id,
name,
type: "private",
status: "active",
createdAt: "2026-05-27T00:00:00.000Z",
redirectUris: [],
scopes: [],
};
}
function makeAuditLog(
eventId: string,
timestamp: string,
action: string,
targetId: string,
details: Record<string, unknown>,
): DevAuditLog {
return {
event_id: eventId,
timestamp,
user_id: "actor-1",
event_type: "AUDIT",
status: "success",
ip_address: "127.0.0.1",
user_agent: "vitest",
details: JSON.stringify({
action,
target_id: targetId,
...details,
}),
};
}
describe("recent client changes", () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, "", "/");
});
function mockLocale(locale: "ko" | "en") {
window.localStorage.clear();
window.history.replaceState({}, "", `/${locale}`);
}
it("translates action labels and relation details by locale", () => {
mockLocale("en");
expect(getRecentClientActionLabel("CREATE_CLIENT")).toBe("App creation");
expect(getRecentClientActionLabel("UPDATE_CLIENT")).toBe(
"Settings changes",
);
expect(getRecentClientActionLabel("UPDATE_CLIENT_STATUS")).toBe(
"Status changes",
);
expect(getRecentClientActionLabel("ROTATE_SECRET")).toBe(
"Client secret rotation",
);
expect(getRecentClientActionLabel("ADD_RELATION")).toBe("Add Relationship");
expect(getRecentClientActionLabel("REMOVE_RELATION")).toBe(
"Remove Relationship",
);
expect(getRecentClientActionLabel("DELETE_CLIENT")).toBe("App deletion");
expect(getRecentClientActionLabel("OTHER_ACTION")).toBe("OTHER_ACTION");
expect(
buildRecentClientChangeDetails("ROTATE_SECRET", {
after: {},
}),
).toEqual([{ label: "Client Secret", value: "Secret Rotated" }]);
expect(
buildRecentClientChangeDetails("ADD_RELATION", {
after: {
relation: "admins",
subject: "User:1",
},
}),
).toEqual([
{ label: "Relation", value: "admins" },
{ label: "Subject", value: "User:1" },
]);
});
it("builds recent client changes with sorting, filtering, and detail slicing", () => {
mockLocale("ko");
const clients = [
makeClient("client-a", "Alpha"),
makeClient("client-b", ""),
];
const auditLogs = [
makeAuditLog(
"evt-1",
"2026-05-27T07:00:00.000Z",
"CREATE_CLIENT",
"client-a",
{
after: { name: "Alpha", type: "private", status: "active" },
},
),
makeAuditLog(
"evt-2",
"2026-05-27T08:00:00.000Z",
"UPDATE_CLIENT",
"client-a",
{
before: {
name: "Alpha old",
status: "inactive",
sameField: "same",
oldField: "old-value",
},
after: {
name: "Alpha new",
status: "active",
sameField: "same",
newField: "new-value",
},
},
),
makeAuditLog(
"evt-3",
"2026-05-27T09:00:00.000Z",
"UPDATE_CLIENT_STATUS",
"client-a",
{
before: { status: "inactive" },
after: { status: "active" },
},
),
makeAuditLog(
"evt-4",
"2026-05-27T10:00:00.000Z",
"ADD_RELATION",
"client-b",
{
after: {
relation: "audit_viewer",
subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
},
),
makeAuditLog(
"evt-5",
"2026-05-27T11:00:00.000Z",
"REMOVE_RELATION",
"client-b",
{
before: {
relation: "admins",
subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
},
),
makeAuditLog(
"evt-6",
"2026-05-27T12:00:00.000Z",
"ROTATE_SECRET",
"client-a",
{
after: {},
},
),
makeAuditLog(
"evt-7",
"2026-05-27T13:00:00.000Z",
"DELETE_CLIENT",
"client-a",
{
before: {
name: "Alpha",
status: "inactive",
},
},
),
makeAuditLog(
"evt-8",
"2026-05-27T14:00:00.000Z",
"UNSUPPORTED_ACTION",
"client-a",
{
after: { name: "Ignored" },
},
),
];
const changes = buildRecentClientChanges(auditLogs, clients);
expect(changes).toHaveLength(7);
expect(changes[0]).toMatchObject({
eventId: "evt-7",
clientName: "Alpha",
actionLabel: "앱 삭제",
});
expect(changes[1]).toMatchObject({
eventId: "evt-6",
clientName: "Alpha",
actionLabel: "클라이언트 시크릿 재발급",
detailLabels: [
{
label: "클라이언트 시크릿",
value: "Client Secret이 재발급되었습니다.",
},
],
});
expect(changes[2]).toMatchObject({
eventId: "evt-5",
clientName: "client-b",
actionLabel: "관계 삭제",
detailLabels: [
{ label: "관계", value: "admins" },
{
label: "주체",
value: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
],
});
expect(changes[4]).toMatchObject({
eventId: "evt-3",
actionLabel: "상태 변경",
clientName: "Alpha",
detailLabels: [{ value: "inactive → active" }],
});
expect(changes[5]).toMatchObject({
eventId: "evt-2",
actionLabel: "설정 변경",
detailLabels: [
{ label: "애플리케이션", value: "Alpha old → Alpha new" },
{ label: "상태", value: "inactive → active" },
{ label: "oldField", value: "old-value" },
],
});
expect(changes[6]).toMatchObject({
eventId: "evt-1",
actionLabel: "앱 생성",
detailLabels: [
{ label: "애플리케이션", value: "Alpha" },
{ label: "유형", value: "private" },
{ label: "상태", value: "active" },
],
});
});
});

View File

@@ -0,0 +1,203 @@
import {
formatAuditValue,
parseAuditDetails,
resolveAuditActor,
type AuditDetails,
type CommonAuditLog,
} from "../../../../common/core/audit";
import { t } from "../../lib/i18n";
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
export type RecentClientChange = {
eventId: string;
clientId: string;
clientName: string;
actorId: string;
action: string;
actionLabel: string;
timestamp: string;
detailLabels: Array<{ label: string; value: string }>;
};
const recentClientActions = new Set([
"CREATE_CLIENT",
"UPDATE_CLIENT",
"UPDATE_CLIENT_STATUS",
"ROTATE_SECRET",
"ADD_RELATION",
"REMOVE_RELATION",
"DELETE_CLIENT",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function getRecentClientActionLabel(action: string) {
switch (action) {
case "CREATE_CLIENT":
return t("ui.dev.clients.recent_changes.guide.create", "앱 생성");
case "UPDATE_CLIENT":
return t("ui.dev.clients.recent_changes.guide.settings", "설정 변경");
case "UPDATE_CLIENT_STATUS":
return t("ui.dev.clients.recent_changes.guide.status", "상태 변경");
case "ROTATE_SECRET":
return t(
"ui.dev.clients.recent_changes.guide.secret",
"클라이언트 시크릿 재발급",
);
case "ADD_RELATION":
return t("ui.dev.clients.relationships.add_title", "관계 추가");
case "REMOVE_RELATION":
return t("ui.dev.clients.relationships.remove_title", "관계 삭제");
case "DELETE_CLIENT":
return t("ui.dev.clients.recent_changes.guide.delete", "앱 삭제");
default:
return action;
}
}
function getRecentClientFieldLabel(key: string) {
switch (key) {
case "name":
return t("ui.dev.clients.table.application", "Application");
case "type":
return t("ui.dev.clients.table.type", "Type");
case "status":
return t("ui.dev.clients.table.status", "Status");
case "relation":
return t("ui.dev.clients.relationships.relation", "관계");
case "subject":
return t("ui.dev.clients.relationships.subject", "주체");
case "client_secret":
return t(
"ui.dev.clients.details.credentials.client_secret",
"클라이언트 시크릿",
);
default:
return key;
}
}
export function buildRecentClientChangeDetails(
action: string,
details: AuditDetails,
) {
const before = isRecord(details.before) ? details.before : {};
const after = isRecord(details.after) ? details.after : {};
if (action === "ROTATE_SECRET") {
return [
{
label: getRecentClientFieldLabel("client_secret"),
value: t("msg.dev.clients.details.secret_rotated", "재발급"),
},
];
}
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
const source = action === "ADD_RELATION" ? after : before;
return [
...(source.relation
? [
{
label: getRecentClientFieldLabel("relation"),
value: formatAuditValue(source.relation),
},
]
: []),
...(source.subject
? [
{
label: getRecentClientFieldLabel("subject"),
value: formatAuditValue(source.subject),
},
]
: []),
];
}
const keys = Array.from(
new Set([...Object.keys(before), ...Object.keys(after)]),
);
const changes = keys
.map((key) => {
const beforeValue = before[key];
const afterValue = after[key];
if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) {
return null;
}
}
const label = getRecentClientFieldLabel(key);
if (action === "CREATE_CLIENT") {
if (afterValue === undefined) {
return null;
}
return { label, value: formatAuditValue(afterValue) };
}
if (action === "DELETE_CLIENT") {
if (beforeValue === undefined) {
return null;
}
return { label, value: formatAuditValue(beforeValue) };
}
if (beforeValue === undefined && afterValue === undefined) {
return null;
}
if (beforeValue === undefined) {
return { label, value: formatAuditValue(afterValue) };
}
if (afterValue === undefined) {
return { label, value: formatAuditValue(beforeValue) };
}
return {
label,
value: `${formatAuditValue(beforeValue)}${formatAuditValue(afterValue)}`,
};
})
.filter((item): item is { label: string; value: string } => Boolean(item));
return changes.slice(0, 3);
}
export function buildRecentClientChanges(
auditLogs: DevAuditLog[],
clients: ClientSummary[],
) {
const clientNameById = new Map(
clients.map((client) => [client.id, client.name || client.id]),
);
return auditLogs
.map((item) => {
const details = parseAuditDetails(item.details);
const action = details.action || "";
const clientId = String(details.target_id || "");
if (!recentClientActions.has(action) || !clientId) {
return null;
}
return {
eventId: item.event_id,
clientId,
clientName: clientNameById.get(clientId) || clientId,
actorId: resolveAuditActor(
item as Pick<CommonAuditLog, "user_id">,
details,
),
action,
actionLabel: getRecentClientActionLabel(action),
timestamp: item.timestamp,
detailLabels: buildRecentClientChangeDetails(action, details),
} satisfies RecentClientChange;
})
.filter((item): item is RecentClientChange => Boolean(item))
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
);
}

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const getUserMock = vi.fn();
const findPersistedOidcUserMock = vi.fn();
const removeUserMock = vi.fn();
const shouldStartLoginRedirectMock = vi.fn();
const shouldSuppressDevelopmentSessionRedirectMock = vi.fn();
vi.mock("./auth", () => ({
userManager: {
getUser: (...args: unknown[]) => getUserMock(...args),
removeUser: (...args: unknown[]) => removeUserMock(...args),
},
}));
vi.mock("./oidcStorage", () => ({
findPersistedOidcUser: (...args: unknown[]) =>
findPersistedOidcUserMock(...args),
}));
vi.mock("../../../common/core/auth", () => ({
shouldStartLoginRedirect: (...args: unknown[]) =>
shouldStartLoginRedirectMock(...args),
}));
vi.mock("../../../common/core/session", () => ({
shouldSuppressDevelopmentSessionRedirect: (...args: unknown[]) =>
shouldSuppressDevelopmentSessionRedirectMock(...args),
}));
describe("apiClient", () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv("MODE", "test");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
window.localStorage.clear();
getUserMock.mockResolvedValue(null);
findPersistedOidcUserMock.mockReturnValue(undefined);
removeUserMock.mockResolvedValue(undefined);
shouldStartLoginRedirectMock.mockReturnValue(true);
shouldSuppressDevelopmentSessionRedirectMock.mockReturnValue(false);
});
it("injects authorization and tenant headers into requests", async () => {
getUserMock.mockResolvedValueOnce({ access_token: "live-token" });
window.localStorage.setItem("dev_tenant_id", "tenant-1");
const { default: apiClient } = await import("./apiClient");
const requestHandler =
apiClient.interceptors.request.handlers[0]?.fulfilled;
const result = await requestHandler?.({ headers: {} });
expect(result.headers.Authorization).toBe("Bearer live-token");
expect(result.headers["X-Tenant-ID"]).toBe("tenant-1");
});
it("rejects non-auth response errors without redirecting", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { default: apiClient } = await import("./apiClient");
const responseHandler =
apiClient.interceptors.response.handlers[0]?.rejected;
const error = { response: { status: 500, data: { error: "boom" } } };
await expect(responseHandler?.(error)).rejects.toBe(error);
expect(warnSpy).not.toHaveBeenCalled();
expect(removeUserMock).not.toHaveBeenCalled();
});
it("warns and rejects auth failures in test mode", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { default: apiClient } = await import("./apiClient");
const responseHandler =
apiClient.interceptors.response.handlers[0]?.rejected;
const error = {
response: {
status: 403,
data: { error: "authentication required" },
},
};
await expect(responseHandler?.(error)).rejects.toBe(error);
expect(warnSpy).toHaveBeenCalled();
expect(removeUserMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { findPersistedOidcUser } from "./oidcStorage";
class MemoryStorage implements Storage {
private data = new Map<string, string>();
get length() {
return this.data.size;
}
clear(): void {
this.data.clear();
}
getItem(key: string): string | null {
return this.data.get(key) ?? null;
}
key(index: number): string | null {
return Array.from(this.data.keys())[index] ?? null;
}
removeItem(key: string): void {
this.data.delete(key);
}
setItem(key: string, value: string): void {
this.data.set(key, value);
}
}
describe("findPersistedOidcUser", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the first valid, unexpired devfront user entry", () => {
const storage = new MemoryStorage();
storage.setItem("oidc.user:issuer:other-client", JSON.stringify({}));
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
storage.setItem(
"oidc.user:issuer:devfront",
JSON.stringify({
access_token: "token-1",
expires_at: expiresAt,
profile: { name: "Dev Admin" },
}),
);
expect(findPersistedOidcUser(storage)).toEqual({
access_token: "token-1",
expires_at: expiresAt,
profile: { name: "Dev Admin" },
});
});
it("skips malformed, empty, and expired entries", () => {
const storage = new MemoryStorage();
storage.setItem("random", "value");
storage.setItem("oidc.user:issuer:devfront", "not-json");
storage.setItem(
"oidc.user:issuer:devfront",
JSON.stringify({
access_token: "expired",
expires_at: Math.floor(Date.now() / 1000) - 1,
}),
);
expect(findPersistedOidcUser(storage)).toBeNull();
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { normalizeRole, resolveProfileRole } from "./role";
describe("normalizeRole", () => {
it("normalizes known role aliases", () => {
expect(normalizeRole("tenant_member")).toBe("user");
expect(normalizeRole("admin")).toBe("tenant_admin");
expect(normalizeRole("superadmin")).toBe("super_admin");
expect(normalizeRole("tenantadmin")).toBe("tenant_admin");
expect(normalizeRole("rpadmin")).toBe("rp_admin");
});
it("returns a trimmed lowercase role for unknown values", () => {
expect(normalizeRole(" custom_role ")).toBe("custom_role");
expect(normalizeRole(123)).toBe("");
});
});
describe("resolveProfileRole", () => {
it("prefers the first non-empty normalized role candidate", () => {
expect(
resolveProfileRole({
role: " ",
grade: "tenant_member",
"custom:role": "admin",
}),
).toBe("user");
});
it("returns an empty string when no role is present", () => {
expect(resolveProfileRole(undefined)).toBe("");
});
});

View File

@@ -544,6 +544,10 @@ new_client = "Configure redirect URIs, grant types, and authentication methods."
empty = "Review the relying parties this account can access."
none = "No connected applications to display."
[msg.dev.dashboard.recent_changes]
description = "Review trends for changed or deleted applications on the dashboard."
empty = "There are no recent change logs yet."
[msg.dev.dashboard.notice]
consent_audit = "Consent Audit"
dev_scope = "Dev Scope"
@@ -1597,6 +1601,7 @@ revoke_cache = "Revoke Cache"
[ui.dev.clients.relationships]
title = "Client Relationships"
add_title = "Add Relationship"
remove_title = "Remove Relationship"
relation = "Relation"
user_id = "User ID"
user_id_placeholder = "kratos user id"
@@ -1741,6 +1746,20 @@ title = "Quick links"
[ui.dev.dashboard.recent]
title = "My Applications"
[ui.dev.dashboard.recent_changes]
deleted_group = "Deleted applications"
aria = "Recent application changes"
period = "Recent change aggregation period"
series = "Changes {{changes}} / Actors {{actors}}"
title = "Recently Changed Applications"
y_axis = "Y axis: change count"
[ui.dev.dashboard.recent_changes.summary]
changed_clients = "Changed apps"
deleted_clients = "Deleted applications"
latest_change = "Latest change"
total_changes = "Recent changes"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"

View File

@@ -544,6 +544,10 @@ new_client = "redirect URI, grant type, 인증 방식을 설정합니다."
empty = "현재 계정이 접근할 수 있는 RP를 확인합니다."
none = "표시할 연동 앱이 없습니다."
[msg.dev.dashboard.recent_changes]
description = "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다."
empty = "최근 변경 로그가 아직 없습니다."
[msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계"
dev_scope = "RP 정책은 dev scope에서만 적용"
@@ -1596,6 +1600,7 @@ revoke_cache = "캐시 삭제"
[ui.dev.clients.relationships]
title = "클라이언트 관계"
add_title = "관계 추가"
remove_title = "관계 삭제"
relation = "관계"
user_id = "사용자 ID"
user_id_placeholder = "kratos 사용자 id"
@@ -1740,6 +1745,20 @@ title = "빠른 이동"
[ui.dev.dashboard.recent]
title = "내 애플리케이션"
[ui.dev.dashboard.recent_changes]
deleted_group = "삭제된 앱"
aria = "최근 변경된 앱 현황"
period = "최근 변경 집계 단위"
series = "변경 {{changes}} / 작업자 {{actors}}"
title = "최근 변경된 앱"
y_axis = "Y축: 변경 수"
[ui.dev.dashboard.recent_changes.summary]
changed_clients = "변경된 앱 수"
deleted_clients = "삭제된 앱 수"
latest_change = "마지막 변경일"
total_changes = "최근 변경 건수"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"

View File

@@ -582,6 +582,10 @@ new_client = ""
empty = ""
none = ""
[msg.dev.dashboard.recent_changes]
description = ""
empty = ""
[msg.dev.dashboard.notice]
consent_audit = ""
dev_scope = ""
@@ -1651,6 +1655,7 @@ revoke_cache = ""
[ui.dev.clients.relationships]
title = ""
add_title = ""
remove_title = ""
relation = ""
user_id = ""
user_id_placeholder = ""
@@ -1797,6 +1802,20 @@ title = ""
[ui.dev.dashboard.recent]
title = ""
[ui.dev.dashboard.recent_changes]
deleted_group = ""
aria = ""
period = ""
series = ""
title = ""
y_axis = ""
[ui.dev.dashboard.recent_changes.summary]
changed_clients = ""
deleted_clients = ""
latest_change = ""
total_changes = ""
[ui.dev.dashboard.stack]
notes = ""
subtitle = ""

View File

@@ -37,6 +37,9 @@ test("clients page loads correctly", async ({ page }) => {
// 페이지 내 주요 텍스트 확인
await expect(page.getByText("연동 앱 목록")).toBeVisible();
await expect(
page.getByText("Total Applications", { exact: true }),
).toHaveCount(0);
// 테이블 헤더 확인
await expect(
@@ -47,7 +50,7 @@ test("clients page loads correctly", async ({ page }) => {
).toBeVisible();
});
test("clients page shows recent RP changes", async ({ page }) => {
test("overview page shows recent RP changes", async ({ page }) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
@@ -89,7 +92,7 @@ test("clients page shows recent RP changes", async ({ page }) => {
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await page.goto("/");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
@@ -100,7 +103,64 @@ test("clients page shows recent RP changes", async ({ page }) => {
).toBeVisible();
});
test("clients page shows user-delete relation cleanup in recent changes", async ({
test("clients page shows only five apps by default and expands with more button", async ({
page,
}) => {
await seedAuth(page, "super_admin");
const clients = Array.from({ length: 6 }, (_, index) =>
makeClient(`client-${index + 1}`, {
name: `Preview App ${index + 1}`,
createdAt: new Date(Date.UTC(2026, 2, 3, 9, 10 - index, 0)).toISOString(),
}),
);
await installDevApiMock(page, {
clients,
consents: [] as Consent[],
auditLogs: [] as AuditLog[],
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await expect(
page.getByRole("heading", { name: "연동 앱 목록" }),
).toBeVisible();
await expect(
page
.locator("table")
.first()
.locator("tbody tr")
.filter({
hasText: /Preview App \d/,
}),
).toHaveCount(5);
await expect(
page.getByText("Preview App 6", { exact: true }),
).not.toBeVisible();
const moreButton = page.getByRole("button", {
name: "연동 앱 목록 더보기",
});
await expect(moreButton).toBeVisible();
await expect(moreButton).toHaveCount(1);
await moreButton.click();
await expect(
page
.locator("table")
.first()
.locator("tbody tr")
.filter({
hasText: /Preview App \d/,
}),
).toHaveCount(6);
await expect(page.getByText("Preview App 6", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "연동 앱 목록 더보기" }),
).toHaveCount(0);
});
test("overview page shows user-delete relation cleanup in recent changes", async ({
page,
}) => {
await seedAuth(page, "super_admin");
@@ -142,7 +202,7 @@ test("clients page shows user-delete relation cleanup in recent changes", async
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await page.goto("/");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
@@ -151,15 +211,13 @@ test("clients page shows user-delete relation cleanup in recent changes", async
).toBeVisible();
await expect(page.getByText("관계 삭제", { exact: true })).toBeVisible();
await expect(page.getByText(/관계:\s*config_editor/)).toBeVisible();
await expect(page.getByText(/대상:\s*User:deleted-user/)).toBeVisible();
await expect(page.getByText(/주체:\s*User:deleted-user/)).toBeVisible();
await expect(
page.getByText("cleanup-actor", { exact: true }).first(),
).toBeVisible();
});
test("clients page expands recent changes with more button", async ({
page,
}) => {
test("clients page no longer shows recent changes card", async ({ page }) => {
await seedAuth(page, "super_admin");
const clients = Array.from({ length: 6 }, (_, index) =>
makeClient(`client-${index + 1}`, {
@@ -193,23 +251,8 @@ test("clients page expands recent changes with more button", async ({
await page.goto("/clients");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
).toHaveCount(0);
await expect(
page.getByRole("link", { name: "Recent App 1", exact: true }),
page.getByRole("heading", { name: "연동 앱 목록" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 5", exact: true }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 6", exact: true }),
).not.toBeVisible();
const moreButton = page.getByRole("button", { name: "더 보기" });
await expect(moreButton).toBeVisible();
await moreButton.click();
await expect(
page.getByRole("link", { name: "Recent App 6", exact: true }),
).toBeVisible();
await expect(moreButton).toHaveCount(0);
});

View File

@@ -0,0 +1,126 @@
# 한라 WORKS 도메인 분리 및 조직 연동 계획
## 현황 확인
- 로컬 seed 기준 `halla``hanmac-family` 직속 `COMPANY`입니다.
- 로컬 DB 기준 `halla``hanmac-family` 직속이 맞지만, 타입은 현재 `ORGANIZATION`입니다.
- 한라 하위 테넌트는 로컬 DB 기준 전체 43개입니다.
- 한라 직속 하위 조직은 10개입니다.
- 경영지원본부
- 기반사업본부
- 기술영업본부
- 시공현장
- 안전관리본부
- 업무총괄
- 영업총괄
- 운영사업소
- 임원실
- 환경플랜트사업본부
## 현재 코드의 문제
현재 WORKS 도메인 분류는 다음 네 가지 도메인만 알고 있습니다.
- `SAMAN_DOMAIN_ID`
- `HANMAC_DOMAIN_ID`
- `GPDTDC_DOMAIN_ID`
- `BARONGROUP_DOMAIN_ID`
`HALLA_DOMAIN_ID`가 없기 때문에 `halla``hallasanup.com`은 별도 도메인 루트로 판정되지 않습니다. 이 상태에서 조직 연동을 실행하면 한라 하위 조직이 HALLA 도메인이 아니라 fallback 도메인으로 분류될 수 있습니다.
또한 로컬 DB에서 `halla` 타입이 `ORGANIZATION`이면 “별도 회사 도메인 루트”라는 의미가 약합니다. seed와 실제 DB를 `COMPANY`로 맞추는 마이그레이션이 필요합니다.
## 목표 구조
Baron 내부 구조:
- `hanmac-family`
- `halla` (`COMPANY`, WORKS domain root, `HALLA_DOMAIN_ID`, `hallasanup.com`)
- 한라 하위 조직들 (`ORGANIZATION`)
한맥가족 직속 회사/그룹 배치 순서:
1. `gpdtdc` - 총괄기획&기술개발센터
2. `saman` - 삼안
3. `hanmac` - 한맥기술
4. `baron-group` - 바론그룹
5. `halla` - 한라산업개발
WORKS Mobile 구조:
- `HALLA_DOMAIN_ID`
- 한라 depth 1 조직은 HALLA 도메인의 최상위 org unit으로 생성합니다.
- 한라 depth 2 이상 조직은 Baron의 부모 조직 external key를 따라 하위 org unit으로 생성합니다.
- `halla` 회사 테넌트 자체는 WORKS org unit으로 만들지 않습니다. 도메인 루트 분류 기준으로만 사용합니다.
예상 매핑:
- `halla-mgmt-support-hq` -> `HALLA_DOMAIN_ID`, `parentOrgUnitId=""`
- `halla-mgmt-support` -> `HALLA_DOMAIN_ID`, `parentOrgUnitId="externalKey:d656c134-a50b-43b9-8c2d-fb3738dd0f9f"`
- `site-gyeongsan-road` -> `HALLA_DOMAIN_ID`, 실제 Baron parent external key 유지
## 구현 계획
1. 도메인 루트 판정 추가
- `isWorksmobileDomainRootTenant``halla`, `hallasanup.com`, `한라산업개발`을 추가합니다.
- `worksmobileTenantDomainIDEnvKey`가 한라 테넌트를 `HALLA_DOMAIN_ID`로 반환하도록 추가합니다.
2. 이메일 도메인 판정 추가
- `ResolveWorksmobileAccountDomainIDFromEmail``hallasanup.com -> HALLA_DOMAIN_ID`를 추가합니다.
- `worksmobileDomainIDEnvKeyFromEmail`에도 같은 매핑을 추가합니다.
- 사용자 주 이메일 또는 alias가 `hallasanup.com`이면 HALLA 계정/조직 이메일로 매핑되도록 검증합니다.
3. WORKS remote 조회 범위 추가
- `worksmobileDomainEnvMappings``HALLA_DOMAIN_ID`와 label `한라산업개발`을 추가합니다.
- `WorksmobileDomainIDsFromEnv`가 HALLA 도메인도 remote user/group 조회 대상으로 포함해야 합니다.
4. 로컬 데이터 마이그레이션
- `halla``hanmac-family` 직속인지 확인합니다.
- `halla` 타입을 `COMPANY`로 맞춥니다.
- `halla` 도메인을 `hallasanup.com`으로 유지합니다.
- 필요한 경우 `halla` 관련 기존 WORKS outbox pending 작업을 정리하거나 재등록합니다.
5. 조직 연동 순서
- 먼저 comparison dry-run으로 HALLA 도메인 예상값을 확인합니다.
- 한라 하위 조직만 대상으로 org unit upsert를 등록합니다.
- WORKS에서 같은 external key가 다른 도메인에 이미 붙어 있으면 기존 도메인 external key를 clear한 뒤 HALLA 도메인에 재등록합니다.
- 조직 연동 성공 후 사용자 연동을 진행합니다.
6. 사용자 연동 기준
- Baron representative/primary가 `halla` 또는 한라 하위 조직이면 `HALLA_DOMAIN_ID` 조직 membership을 생성합니다.
- 주 이메일이 `hallasanup.com`이면 HALLA domain account로 생성합니다.
- 다른 회사 주 이메일을 가진 겸직 사용자는 계정 domain은 주 이메일 기준으로 유지하고, HALLA 조직은 `organizations[]`의 추가 조직으로 매핑합니다.
## 테스트 계획
- `worksmobile_mapper_test.go`
- `halla``hallasanup.com``HALLA_DOMAIN_ID`로 resolve되는지 검증합니다.
- `hallasanup.com` 이메일이 HALLA account domain으로 resolve되는지 검증합니다.
- `WorksmobileDomainIDsFromEnv`에 HALLA 도메인이 포함되는지 검증합니다.
- `worksmobile_sync_service_test.go`
- 한맥가족 직속 회사 `halla`를 domain root로 판정하는 테스트를 추가합니다.
- 한라 depth 1 조직은 `parentOrgUnitId=""`로 생성되는지 검증합니다.
- 한라 depth 2 조직은 `parentOrgUnitId="externalKey:<parent>"`로 생성되는지 검증합니다.
- comparison에서 같은 external key가 다른 domain에 있으면 HALLA domain 기준으로 update/rekey 대상이 되는지 검증합니다.
- live/E2E
- `HALLA_DOMAIN_ID`가 설정된 환경에서 한라 하위 조직 org unit provisioning dry-run을 실행합니다.
- 실제 upsert 후 worksmobile 메뉴의 최근 작업에서 processed/failed 및 실패 사유를 확인합니다.
## 운영 순서 제안
1. `HALLA_DOMAIN_ID` 환경값을 dev/local에 먼저 설정합니다.
2. 코드에 HALLA 도메인 분류를 추가하고 테스트를 통과시킵니다.
3. 로컬 DB의 `halla` 타입을 `COMPANY`로 마이그레이션합니다.
4. worksmobile comparison에서 한라 하위 조직만 필터링해 예상 도메인과 parent를 확인합니다.
5. 조직 upsert를 먼저 수행합니다.
6. 실패 작업이 있으면 최근 작업 이력에서 원인을 확인하고 external key 충돌부터 해소합니다.
7. 조직이 모두 정상 처리된 뒤 사용자 sync를 진행합니다.
## 주의점
- `halla` 회사 테넌트 자체를 org unit으로 만들면 HALLA 도메인 최상위에 “한라산업개발” 조직이 중복으로 생길 수 있습니다.
- 기존에 한라 하위 조직 external key가 `BARONGROUP_DOMAIN_ID`에 생성되어 있으면, WORKS API가 같은 external key의 다른 domain 중복을 허용하지 않을 수 있습니다.
- 사용자 sync는 조직 upsert가 끝난 뒤 진행해야 `organizations[].orgUnits[].orgUnitId` 참조 실패를 줄일 수 있습니다.
- 로컬 DB 타입과 seed 타입이 다르면 이후 seed/마이그레이션 테스트가 계속 흔들릴 수 있으므로 DB 보정이 먼저 필요합니다.

View File

@@ -595,6 +595,10 @@ description = "Quickly review application types and headless login usage."
empty = "Review the RPs this account can access."
none = "No linked applications are available."
[msg.dev.dashboard.recent_changes]
description = "Review trends for changed or deleted applications on the dashboard."
empty = "There are no recent change logs yet."
[msg.dev.dashboard.notice]
consent_audit = "Consent Audit"
dev_scope = "Dev Scope"
@@ -2308,6 +2312,20 @@ title = "Quick links"
[ui.dev.dashboard.recent]
title = "My Applications"
[ui.dev.dashboard.recent_changes]
aria = "Recent changed application status"
deleted_group = "Deleted applications"
period = "Recent change aggregation period"
series = "Changes {{changes}} / Actors {{actors}}"
title = "Recent Changed Apps"
y_axis = "Y axis: change count"
[ui.dev.dashboard.recent_changes.summary]
changed_clients = "Changed applications"
deleted_clients = "Deleted applications"
latest_change = "Latest change"
total_changes = "Recent change count"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"

View File

@@ -1087,6 +1087,10 @@ description = "애플리케이션 유형과 headless login 사용 현황을 빠
empty = "현재 계정이 접근할 수 있는 RP를 확인합니다."
none = "표시할 연동 앱이 없습니다."
[msg.dev.dashboard.recent_changes]
description = "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다."
empty = "최근 변경 로그가 아직 없습니다."
[msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계"
dev_scope = "RP 정책은 dev scope에서만 적용"
@@ -2772,6 +2776,20 @@ title = "빠른 이동"
[ui.dev.dashboard.recent]
title = "내 애플리케이션"
[ui.dev.dashboard.recent_changes]
aria = "최근 변경된 앱 현황"
deleted_group = "삭제된 앱"
period = "최근 변경 집계 단위"
series = "변경 {{changes}} / 작업자 {{actors}}"
title = "최근 변경된 앱"
y_axis = "Y축: 변경 수"
[ui.dev.dashboard.recent_changes.summary]
changed_clients = "변경된 앱 수"
deleted_clients = "삭제된 앱 수"
latest_change = "마지막 변경일"
total_changes = "최근 변경 건수"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"

View File

@@ -947,6 +947,10 @@ description = ""
empty = ""
none = ""
[msg.dev.dashboard.recent_changes]
description = ""
empty = ""
[msg.dev.dashboard.notice]
consent_audit = ""
dev_scope = ""
@@ -2653,6 +2657,20 @@ title = ""
[ui.dev.dashboard.recent]
title = ""
[ui.dev.dashboard.recent_changes]
aria = ""
deleted_group = ""
period = ""
series = ""
title = ""
y_axis = ""
[ui.dev.dashboard.recent_changes.summary]
changed_clients = ""
deleted_clients = ""
latest_change = ""
total_changes = ""
[ui.dev.dashboard.stack]
notes = ""
subtitle = ""

View File

@@ -1,52 +1,64 @@
import { describe, expect, it } from "vitest";
import {
getHanmacFamilyTenantOrderRank,
orderHanmacFamilyChildren,
orderHanmacFamilyTenants,
getHanmacFamilyTenantOrderRank,
orderHanmacFamilyChildren,
orderHanmacFamilyTenants,
} from "./hanmacFamilyOrder";
function tenant(name: string, slug: string) {
return { name, slug };
return { name, slug };
}
describe("hanmac family organization order", () => {
it("orders the top hanmac-family siblings by policy", () => {
const ordered = orderHanmacFamilyTenants([
tenant("바론그룹", "baron-group"),
tenant("한맥기술", "hanmac"),
tenant("삼안", "saman"),
tenant("총괄기획&기술개발센터", "gpdtdc"),
]);
it("orders the top hanmac-family siblings by policy", () => {
const ordered = orderHanmacFamilyTenants([
tenant("한라산업개발", "halla"),
tenant("바론그룹", "baron-group"),
tenant("한맥기술", "hanmac"),
tenant("삼안", "saman"),
tenant("총괄기획&기술개발센터", "gpdtdc"),
]);
expect(ordered.map((item) => item.name)).toEqual([
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
expect(ordered.map((item) => item.name)).toEqual([
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
"한라산업개발",
]);
});
it("keeps hanmac-family as the root before ordered descendants", () => {
const family = tenant("한맥가족", "hanmac-family");
const children = orderHanmacFamilyChildren(family, [
tenant("바론그룹", "baron-group"),
tenant("총괄기획&기술개발센터", "gpdtdc"),
tenant("삼안", "saman"),
tenant("한맥기술", "hanmac"),
]);
it("keeps hanmac-family as the root before ordered descendants", () => {
const family = tenant("한맥가족", "hanmac-family");
const children = orderHanmacFamilyChildren(family, [
tenant("바론그룹", "baron-group"),
tenant("총괄기획&기술개발센터", "gpdtdc"),
tenant("삼안", "saman"),
tenant("한라산업개발", "halla"),
tenant("한맥기술", "hanmac"),
]);
expect([family, ...children].map((item) => item.name)).toEqual([
"한맥가족",
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
expect([family, ...children].map((item) => item.name)).toEqual([
"한맥가족",
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
"한라산업개발",
]);
});
it("does not rank generic technical centers as GPDTDC", () => {
expect(
getHanmacFamilyTenantOrderRank(tenant("기술개발센터", "rnd-center")),
).toBe(Number.MAX_SAFE_INTEGER);
});
it("does not rank generic technical centers as GPDTDC", () => {
expect(
getHanmacFamilyTenantOrderRank(
tenant("기술개발센터", "rnd-center"),
),
).toBe(Number.MAX_SAFE_INTEGER);
});
it("ranks Halla as the fifth hanmac-family company", () => {
expect(
getHanmacFamilyTenantOrderRank(tenant("한라산업개발", "halla")),
).toBe(4);
});
});

View File

@@ -1,65 +1,67 @@
export type HanmacFamilyOrderTenant = {
name: string;
slug: string;
name: string;
slug: string;
};
export const HANMAC_FAMILY_ROOT_SLUG = "hanmac-family";
export const HANMAC_FAMILY_TENANT_ORDER = [
"gpdtdc",
"saman",
"hanmac",
"baron-group",
"gpdtdc",
"saman",
"hanmac",
"baron-group",
"halla",
] as const;
function normalizedTenantText(tenant: HanmacFamilyOrderTenant) {
return `${tenant.slug} ${tenant.name}`.trim().toLowerCase();
return `${tenant.slug} ${tenant.name}`.trim().toLowerCase();
}
export function isHanmacFamilyRootTenant(tenant: HanmacFamilyOrderTenant) {
return (
tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG ||
tenant.name.includes("한맥가족")
);
return (
tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG ||
tenant.name.includes("한맥가족")
);
}
export function getHanmacFamilyTenantOrderRank(
tenant: HanmacFamilyOrderTenant,
tenant: HanmacFamilyOrderTenant,
) {
const text = normalizedTenantText(tenant);
if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0;
if (text.includes("saman") || text.includes("삼안")) return 1;
if (
(text.includes("hanmac") || text.includes("한맥기술")) &&
!isHanmacFamilyRootTenant(tenant)
) {
return 2;
}
if (text.includes("baron-group") || text.includes("바론그룹")) return 3;
return Number.MAX_SAFE_INTEGER;
const text = normalizedTenantText(tenant);
if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0;
if (text.includes("saman") || text.includes("삼안")) return 1;
if (
(text.includes("hanmac") || text.includes("한맥기술")) &&
!isHanmacFamilyRootTenant(tenant)
) {
return 2;
}
if (text.includes("baron-group") || text.includes("바론그룹")) return 3;
if (text.includes("halla") || text.includes("한라산업개발")) return 4;
return Number.MAX_SAFE_INTEGER;
}
export function compareHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
a: T,
b: T,
a: T,
b: T,
) {
const rankDiff =
getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b);
if (rankDiff !== 0) return rankDiff;
return a.name.localeCompare(b.name);
const rankDiff =
getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b);
if (rankDiff !== 0) return rankDiff;
return a.name.localeCompare(b.name);
}
export function orderHanmacFamilyTenants<T extends HanmacFamilyOrderTenant>(
tenants: readonly T[],
tenants: readonly T[],
) {
return [...tenants].sort(compareHanmacFamilyTenants);
return [...tenants].sort(compareHanmacFamilyTenants);
}
export function orderHanmacFamilyChildren<T extends HanmacFamilyOrderTenant>(
parent: HanmacFamilyOrderTenant,
children: readonly T[],
parent: HanmacFamilyOrderTenant,
children: readonly T[],
) {
return isHanmacFamilyRootTenant(parent)
? orderHanmacFamilyTenants(children)
: [...children];
return isHanmacFamilyRootTenant(parent)
? orderHanmacFamilyTenants(children)
: [...children];
}

View File

@@ -3,179 +3,237 @@ import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { buildOrgPickerTree } from "./pickerTree";
function tenant(
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
describe("buildOrgPickerTree", () => {
it("uses the hanmac-family company-group as the default picker root", () => {
const tenants = [
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
tenant(
"wrong-company",
"COMPANY",
"Wrong Company",
"wrong-company",
"wrong-group",
),
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
];
it("uses the hanmac-family company-group as the default picker root", () => {
const tenants = [
tenant(
"wrong-group",
"COMPANY_GROUP",
"Wrong Group",
"wrong-group",
),
tenant(
"wrong-company",
"COMPANY",
"Wrong Company",
"wrong-company",
"wrong-group",
),
tenant(
"hanmac-family-id",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
});
expect(tree.companyGroupId).toBe("hanmac-family-id");
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"saman-id",
]);
});
expect(tree.companyGroupId).toBe("hanmac-family-id");
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"saman-id",
]);
});
it("orders hanmac-family children by the shared organization policy", () => {
const tenants = [
tenant(
"hanmac-family-id",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
),
tenant(
"baron-group-id",
"COMPANY_GROUP",
"바론그룹",
"baron-group",
"hanmac-family-id",
),
tenant(
"hanmac-id",
"COMPANY",
"한맥기술",
"hanmac",
"hanmac-family-id",
),
tenant(
"halla-id",
"COMPANY",
"한라산업개발",
"halla",
"hanmac-family-id",
),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant(
"gpdtdc-id",
"ORGANIZATION",
"총괄기획&기술개발센터",
"gpdtdc",
"hanmac-family-id",
),
];
it("orders hanmac-family children by the shared organization policy", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant(
"baron-group-id",
"COMPANY_GROUP",
"바론그룹",
"baron-group",
"hanmac-family-id",
),
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant(
"gpdtdc-id",
"ORGANIZATION",
"총괄기획&기술개발센터",
"gpdtdc",
"hanmac-family-id",
),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
});
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
"한라산업개발",
]);
});
expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
it("scopes descendant filtering by tenant slug", () => {
const tenants = [
tenant(
"hanmac-family-id",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant(
"planning-id",
"ORGANIZATION",
"기획팀",
"planning",
"saman-id",
),
tenant(
"hanmac-id",
"COMPANY",
"한맥기술",
"hanmac",
"hanmac-family-id",
),
];
it("scopes descendant filtering by tenant slug", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant("planning-id", "ORGANIZATION", "기획팀", "planning", "saman-id"),
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("saman-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"planning-id",
]);
});
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("saman-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"planning-id",
]);
});
it("excludes internal and private tenants from picker choices by default", () => {
const tenants = [
tenant(
"hanmac-family-id",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
{
...tenant(
"internal-id",
"ORGANIZATION",
"내부 조직",
"internal",
"saman-id",
),
config: { visibility: "internal" },
},
{
...tenant(
"secret-id",
"ORGANIZATION",
"비공개 조직",
"secret",
"saman-id",
),
config: { visibility: "private" },
},
tenant(
"secret-child-id",
"USER_GROUP",
"비공개 하위",
"secret-child",
"secret-id",
),
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
];
it("excludes internal and private tenants from picker choices by default", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
{
...tenant(
"internal-id",
"ORGANIZATION",
"내부 조직",
"internal",
"saman-id",
),
config: { visibility: "internal" },
},
{
...tenant(
"secret-id",
"ORGANIZATION",
"비공개 조직",
"secret",
"saman-id",
),
config: { visibility: "private" },
},
tenant(
"secret-child-id",
"USER_GROUP",
"비공개 하위",
"secret-child",
"secret-id",
),
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"open-id",
]);
});
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual(["open-id"]);
});
it("includes internal tenants when explicitly requested", () => {
const tenants = [
tenant(
"hanmac-family-id",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
{
...tenant(
"internal-id",
"ORGANIZATION",
"내부 조직",
"internal",
"saman-id",
),
config: { visibility: "internal" },
},
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
];
it("includes internal tenants when explicitly requested", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
{
...tenant(
"internal-id",
"ORGANIZATION",
"내부 조직",
"internal",
"saman-id",
),
config: { visibility: "internal" },
},
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
];
const tree = buildOrgPickerTree({
includeInternal: true,
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
const tree = buildOrgPickerTree({
includeInternal: true,
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"internal-id",
"open-id",
]);
});
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"internal-id",
"open-id",
]);
});
});

View File

@@ -1,46 +1,46 @@
tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type
cd1ebc22-4b5e-4242-bb87-eb88db32286c,업무,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,operations,,,public,
0e13d342-d3cf-46b5-8096-4e7883b79b01,서산시자원회수시설,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-seosan-recovery,,,public,
41118f16-7f5c-4209-bd83-183822bc00ed,안성제4차산업단지폐수처리,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-anseong-wwtp,,,public,
ad6f20e9-7928-4322-932c-7c3cb2a313cb,온산바이오,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-onsan-bio,,,public,
03d8cf87-4b40-4784-a6cf-fcc11371f40f,울산민자소각,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-ulsan-incineration,,,public,
d7379c32-0b79-482e-9c4d-d83ad425c3fc,운영사업소,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-operation-sites,,,public,
551991d8-1f74-4ad0-a0c5-bc5a11968398,부산항 신항,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-busan-new-port,,,public,
e77b4bf1-a126-4b4e-a18a-8d905e958873,수도권광역급행철도B 제4공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gtx-b-4,,,public,
3b5151f6-1a01-484a-bfb7-2e60d2aa0b49,경산시 국도대체,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gyeongsan-road,,,public,
44c6e400-daf0-42a2-90df-945921788f99,인덕원 동탄 복선전철 제7공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-indeokwon-dongtan-7,,,public,
2bc22118-9a70-4d5b-8a3f-cf65432a8bbb,인덕원 동탄 복선전철 제3공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-indeokwon-dongtan-3,,,public,
1134fa6a-9b0b-4702-a1e7-39948c8c451a,제주공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-jeju-sewage,,,public,
36aec47e-90fc-42cb-8229-3e20423d0424,성남시생활폐기물처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-seongnam-waste,,,public,
5b0b806c-f189-46ea-8771-ebdafcf45afa,광탄공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gwangtan-sewage,,,public,
d2323d9a-c959-48c0-831b-4bb71e48b2e5,인천국제공항 화물,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-incheon-air-cargo,,,public,
32a83ce1-03f1-4daa-b60f-8e64fad83ac6,수도권매립지 제2매립장,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-sudokwon-landfill-2,,,public,
e39ba0af-c3e9-429b-91ec-0a3453a5692e,온산하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-onsan-sewage,,,public,
25f51047-3108-4ff3-98f4-b7f5bce334c5,신천공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-sincheon-sewage,,,public,
f0ae9e81-65a5-4bab-a98d-79349bbaa501,장량공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-jangnyang-sewage,,,public,
b662cfdb-aae3-48b7-b1d3-2ef050dce027,아포공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-apo-sewage,,,public,
76808046-cd35-4813-b240-c323291fa2d8,광주공공폐수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gwangju-wastewater,,,public,
bda54a49-8282-4f91-9773-645e6a1f2a3b,도척 실촌간 도로,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-docheok-silchon-road,,,public,
02a9e89b-a0a0-4202-adde-870194c35351,여주부평천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-yeoju-bupyeongcheon,,,public,
9b1fb915-f50b-49b9-a9f9-a9089a825b1f,옥정 공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-okjeong-sewage,,,public,
29d9fa54-6d8d-49f5-98ca-2c720aced55e,부천시 굴포천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-bucheon-gulpocheon,,,public,
99199302-f04f-47ad-9f9f-2afe2db9826a,시공현장,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-construction-sites,,,public,
2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee,안전관리팀,ORGANIZATION,4b81d408-d81c-43c7-9f87-b6c806db4d7b,hanlla-safety-hq,hanlla-safety-team,,,public,
4b81d408-d81c-43c7-9f87-b6c806db4d7b,안전관리본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-safety-hq,,,public,
69aa9667-4997-41f8-9898-e470cfc778e5,기술영업팀,ORGANIZATION,1512e429-fb95-4c0d-9409-f0a3286061f2,hanlla-tech-sales-hq,hanlla-tech-sales-team,,,public,
1512e429-fb95-4c0d-9409-f0a3286061f2,기술영업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-tech-sales-hq,,,public,
6f9e45f7-63fb-464e-b47c-915fa25f782f,설계팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,hanlla-env-plant-hq,hanlla-env-plant-design,,,public,
69d6d246-b281-4da7-be83-fede8e3dc5bd,사업관리팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,hanlla-env-plant-hq,hanlla-env-project-mgmt,,,public,
2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,환경플랜트사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-env-plant-hq,,,public,
45519f6d-ba67-42e2-9b54-f80f1a950a8c,사업관리팀,ORGANIZATION,1d5da961-7f32-4032-a86c-26e8edbcb8ee,hanlla-infra-business-hq,hanlla-infra-project-mgmt,,,public,
1d5da961-7f32-4032-a86c-26e8edbcb8ee,기반사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-infra-business-hq,,,public,
03af0690-af91-468a-9892-0152c7309a4b,운영사업실,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-operations-office,,,public,
6e9b627f-5304-4e7d-99fc-77fc2328d004,경영지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-mgmt-support,,,public,
43c0fb29-84dd-49d2-a2b8-33b6659f4607,사업지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-business-support,,,public,
d656c134-a50b-43b9-8c2d-fb3738dd0f9f,경영지원본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-mgmt-support-hq,,,public,
940cc09c-32f5-4a02-8213-fb02521189d0,영업총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-general-sales,,,public,
57496cae-a081-4836-a20e-75c78b62257f,업무총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-general-business,,,public,
81e94e6c-e27a-4e36-b0f9-bf8823c96493,임원실,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-executive,,,public,
0e13d342-d3cf-46b5-8096-4e7883b79b01,서산시자원회수시설,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-seosan-recovery,,,public,
41118f16-7f5c-4209-bd83-183822bc00ed,안성제4차산업단지폐수처리,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-anseong-wwtp,,,public,
ad6f20e9-7928-4322-932c-7c3cb2a313cb,온산바이오,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-onsan-bio,,,public,
03d8cf87-4b40-4784-a6cf-fcc11371f40f,울산민자소각,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-ulsan-incineration,,,public,
d7379c32-0b79-482e-9c4d-d83ad425c3fc,운영사업소,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-operation-sites,,,public,
551991d8-1f74-4ad0-a0c5-bc5a11968398,부산항 신항,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-busan-new-port,,,public,
e77b4bf1-a126-4b4e-a18a-8d905e958873,수도권광역급행철도B 제4공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gtx-b-4,,,public,
3b5151f6-1a01-484a-bfb7-2e60d2aa0b49,경산시 국도대체,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gyeongsan-road,,,public,
44c6e400-daf0-42a2-90df-945921788f99,인덕원 동탄 복선전철 제7공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-indeokwon-dongtan-7,,,public,
2bc22118-9a70-4d5b-8a3f-cf65432a8bbb,인덕원 동탄 복선전철 제3공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-indeokwon-dongtan-3,,,public,
1134fa6a-9b0b-4702-a1e7-39948c8c451a,제주공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-jeju-sewage,,,public,
36aec47e-90fc-42cb-8229-3e20423d0424,성남시생활폐기물처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-seongnam-waste,,,public,
5b0b806c-f189-46ea-8771-ebdafcf45afa,광탄공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gwangtan-sewage,,,public,
d2323d9a-c959-48c0-831b-4bb71e48b2e5,인천국제공항 화물,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-incheon-air-cargo,,,public,
32a83ce1-03f1-4daa-b60f-8e64fad83ac6,수도권매립지 제2매립장,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-sudokwon-landfill-2,,,public,
e39ba0af-c3e9-429b-91ec-0a3453a5692e,온산하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-onsan-sewage,,,public,
25f51047-3108-4ff3-98f4-b7f5bce334c5,신천공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-sincheon-sewage,,,public,
f0ae9e81-65a5-4bab-a98d-79349bbaa501,장량공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-jangnyang-sewage,,,public,
b662cfdb-aae3-48b7-b1d3-2ef050dce027,아포공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-apo-sewage,,,public,
76808046-cd35-4813-b240-c323291fa2d8,광주공공폐수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gwangju-wastewater,,,public,
bda54a49-8282-4f91-9773-645e6a1f2a3b,도척 실촌간 도로,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-docheok-silchon-road,,,public,
02a9e89b-a0a0-4202-adde-870194c35351,여주부평천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-yeoju-bupyeongcheon,,,public,
9b1fb915-f50b-49b9-a9f9-a9089a825b1f,옥정 공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-okjeong-sewage,,,public,
29d9fa54-6d8d-49f5-98ca-2c720aced55e,부천시 굴포천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-bucheon-gulpocheon,,,public,
99199302-f04f-47ad-9f9f-2afe2db9826a,시공현장,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-construction-sites,,,public,
2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee,안전관리팀,ORGANIZATION,4b81d408-d81c-43c7-9f87-b6c806db4d7b,halla-safety-hq,halla-safety-team,,,public,
4b81d408-d81c-43c7-9f87-b6c806db4d7b,안전관리본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-safety-hq,,,public,
69aa9667-4997-41f8-9898-e470cfc778e5,기술영업팀,ORGANIZATION,1512e429-fb95-4c0d-9409-f0a3286061f2,halla-tech-sales-hq,halla-tech-sales-team,,,public,
1512e429-fb95-4c0d-9409-f0a3286061f2,기술영업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-tech-sales-hq,,,public,
6f9e45f7-63fb-464e-b47c-915fa25f782f,설계팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,halla-env-plant-hq,halla-env-plant-design,,,public,
69d6d246-b281-4da7-be83-fede8e3dc5bd,사업관리팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,halla-env-plant-hq,halla-env-project-mgmt,,,public,
2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,환경플랜트사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-env-plant-hq,,,public,
45519f6d-ba67-42e2-9b54-f80f1a950a8c,사업관리팀,ORGANIZATION,1d5da961-7f32-4032-a86c-26e8edbcb8ee,halla-infra-business-hq,halla-infra-project-mgmt,,,public,
1d5da961-7f32-4032-a86c-26e8edbcb8ee,기반사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-infra-business-hq,,,public,
03af0690-af91-468a-9892-0152c7309a4b,운영사업실,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-operations-office,,,public,
6e9b627f-5304-4e7d-99fc-77fc2328d004,경영지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-mgmt-support,,,public,
43c0fb29-84dd-49d2-a2b8-33b6659f4607,사업지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-business-support,,,public,
d656c134-a50b-43b9-8c2d-fb3738dd0f9f,경영지원본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-mgmt-support-hq,,,public,
940cc09c-32f5-4a02-8213-fb02521189d0,영업총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-general-sales,,,public,
57496cae-a081-4836-a20e-75c78b62257f,업무총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-general-business,,,public,
81e94e6c-e27a-4e36-b0f9-bf8823c96493,임원실,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-executive,,,public,
786dd00c-b0c1-4db9-b25b-1afecd6a7a41,안전관리,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-safety-management,,,public,
5fbf6f2c-6b12-4124-a457-d1064dbb8677,현장,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-site,,,public,
dd82bb7b-43d8-4744-ab65-9b47ea492ac4,공무,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-construction-admin,,,public,
@@ -125,7 +125,7 @@ fe58cad4-1fa6-4b87-a2eb-51b9ac41320e,사업개발실,ORGANIZATION,9caf62e1-297d-
01fcbee1-df33-4ee9-bf2b-6d9eb81917d9,대외협력팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,external-relations,,,public,
cdc40c0b-f985-461a-be18-f8c8e82f31e8,재무회계팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,finance,,,public,
c6aa2133-ded0-451c-b51b-27faa8b56507,PQ팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,pq-team,,,public,
ca54cffe-ad30-4f9e-983a-88a85c70404d,업무팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-operations,,,public,
ca54cffe-ad30-4f9e-983a-88a85c70404d,업무팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-operations,,,public,
a16f49c4-6828-4fde-a164-43099c4560c4,기획부,ORGANIZATION,9bf67270-e15e-4278-b407-02dec5672876,business-strategy,planning,,,public,
9bf67270-e15e-4278-b407-02dec5672876,경영전략본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,business-strategy,,,public,
896da8ab-50b7-4a63-abbc-c85037b63acc,시공BIM,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,construction-bim,,,public,
@@ -179,7 +179,7 @@ c6b1266c-564b-4543-baba-d78807a3d1b4,경영기획,ORGANIZATION,761a8725-9c19-442
761a8725-9c19-442c-986c-0319e33a5b1e,총괄기획실,ORGANIZATION,5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,gpdtdc,gpd,,,public,
e57cb22c-383e-4489-8c2f-0c5431917e86,PTC,COMPANY,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,ptc,,,public,
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,,personal,개인 사용자 기본 루트 테넌트,,public,
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,hanlla,,,public,
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,halla,,,public,
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon-sanup,,,public,
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon,,,public,
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,038326b6-954a-48a7-a85f-efd83f62b82a,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,,public,
1 tenant_id name type parent_tenant_id parent_tenant_slug slug memo email_domain visibility org_unit_type
2 cd1ebc22-4b5e-4242-bb87-eb88db32286c 업무 ORGANIZATION a16f49c4-6828-4fde-a164-43099c4560c4 planning operations public
3 0e13d342-d3cf-46b5-8096-4e7883b79b01 서산시자원회수시설 ORGANIZATION d7379c32-0b79-482e-9c4d-d83ad425c3fc hanlla-operation-sites halla-operation-sites ops-seosan-recovery public
4 41118f16-7f5c-4209-bd83-183822bc00ed 안성제4차산업단지폐수처리 ORGANIZATION d7379c32-0b79-482e-9c4d-d83ad425c3fc hanlla-operation-sites halla-operation-sites ops-anseong-wwtp public
5 ad6f20e9-7928-4322-932c-7c3cb2a313cb 온산바이오 ORGANIZATION d7379c32-0b79-482e-9c4d-d83ad425c3fc hanlla-operation-sites halla-operation-sites ops-onsan-bio public
6 03d8cf87-4b40-4784-a6cf-fcc11371f40f 울산민자소각 ORGANIZATION d7379c32-0b79-482e-9c4d-d83ad425c3fc hanlla-operation-sites halla-operation-sites ops-ulsan-incineration public
7 d7379c32-0b79-482e-9c4d-d83ad425c3fc 운영사업소 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-operation-sites halla-operation-sites public
8 551991d8-1f74-4ad0-a0c5-bc5a11968398 부산항 신항 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-busan-new-port public
9 e77b4bf1-a126-4b4e-a18a-8d905e958873 수도권광역급행철도B 제4공구 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-gtx-b-4 public
10 3b5151f6-1a01-484a-bfb7-2e60d2aa0b49 경산시 국도대체 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-gyeongsan-road public
11 44c6e400-daf0-42a2-90df-945921788f99 인덕원 동탄 복선전철 제7공구 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-indeokwon-dongtan-7 public
12 2bc22118-9a70-4d5b-8a3f-cf65432a8bbb 인덕원 동탄 복선전철 제3공구 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-indeokwon-dongtan-3 public
13 1134fa6a-9b0b-4702-a1e7-39948c8c451a 제주공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-jeju-sewage public
14 36aec47e-90fc-42cb-8229-3e20423d0424 성남시생활폐기물처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-seongnam-waste public
15 5b0b806c-f189-46ea-8771-ebdafcf45afa 광탄공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-gwangtan-sewage public
16 d2323d9a-c959-48c0-831b-4bb71e48b2e5 인천국제공항 화물 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-incheon-air-cargo public
17 32a83ce1-03f1-4daa-b60f-8e64fad83ac6 수도권매립지 제2매립장 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-sudokwon-landfill-2 public
18 e39ba0af-c3e9-429b-91ec-0a3453a5692e 온산하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-onsan-sewage public
19 25f51047-3108-4ff3-98f4-b7f5bce334c5 신천공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-sincheon-sewage public
20 f0ae9e81-65a5-4bab-a98d-79349bbaa501 장량공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-jangnyang-sewage public
21 b662cfdb-aae3-48b7-b1d3-2ef050dce027 아포공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-apo-sewage public
22 76808046-cd35-4813-b240-c323291fa2d8 광주공공폐수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-gwangju-wastewater public
23 bda54a49-8282-4f91-9773-645e6a1f2a3b 도척 실촌간 도로 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-docheok-silchon-road public
24 02a9e89b-a0a0-4202-adde-870194c35351 여주부평천 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-yeoju-bupyeongcheon public
25 9b1fb915-f50b-49b9-a9f9-a9089a825b1f 옥정 공공하수처리 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-okjeong-sewage public
26 29d9fa54-6d8d-49f5-98ca-2c720aced55e 부천시 굴포천 ORGANIZATION 99199302-f04f-47ad-9f9f-2afe2db9826a hanlla-construction-sites halla-construction-sites site-bucheon-gulpocheon public
27 99199302-f04f-47ad-9f9f-2afe2db9826a 시공현장 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-construction-sites halla-construction-sites public
28 2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee 안전관리팀 ORGANIZATION 4b81d408-d81c-43c7-9f87-b6c806db4d7b hanlla-safety-hq halla-safety-hq hanlla-safety-team halla-safety-team public
29 4b81d408-d81c-43c7-9f87-b6c806db4d7b 안전관리본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-safety-hq halla-safety-hq public
30 69aa9667-4997-41f8-9898-e470cfc778e5 기술영업팀 ORGANIZATION 1512e429-fb95-4c0d-9409-f0a3286061f2 hanlla-tech-sales-hq halla-tech-sales-hq hanlla-tech-sales-team halla-tech-sales-team public
31 1512e429-fb95-4c0d-9409-f0a3286061f2 기술영업본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-tech-sales-hq halla-tech-sales-hq public
32 6f9e45f7-63fb-464e-b47c-915fa25f782f 설계팀 ORGANIZATION 2ea01ea1-6d09-4997-ba67-73cbae7aa7dd hanlla-env-plant-hq halla-env-plant-hq hanlla-env-plant-design halla-env-plant-design public
33 69d6d246-b281-4da7-be83-fede8e3dc5bd 사업관리팀 ORGANIZATION 2ea01ea1-6d09-4997-ba67-73cbae7aa7dd hanlla-env-plant-hq halla-env-plant-hq hanlla-env-project-mgmt halla-env-project-mgmt public
34 2ea01ea1-6d09-4997-ba67-73cbae7aa7dd 환경플랜트사업본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-env-plant-hq halla-env-plant-hq public
35 45519f6d-ba67-42e2-9b54-f80f1a950a8c 사업관리팀 ORGANIZATION 1d5da961-7f32-4032-a86c-26e8edbcb8ee hanlla-infra-business-hq halla-infra-business-hq hanlla-infra-project-mgmt halla-infra-project-mgmt public
36 1d5da961-7f32-4032-a86c-26e8edbcb8ee 기반사업본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-infra-business-hq halla-infra-business-hq public
37 03af0690-af91-468a-9892-0152c7309a4b 운영사업실 ORGANIZATION d656c134-a50b-43b9-8c2d-fb3738dd0f9f hanlla-mgmt-support-hq halla-mgmt-support-hq hanlla-operations-office halla-operations-office public
38 6e9b627f-5304-4e7d-99fc-77fc2328d004 경영지원팀 ORGANIZATION d656c134-a50b-43b9-8c2d-fb3738dd0f9f hanlla-mgmt-support-hq halla-mgmt-support-hq hanlla-mgmt-support halla-mgmt-support public
39 43c0fb29-84dd-49d2-a2b8-33b6659f4607 사업지원팀 ORGANIZATION d656c134-a50b-43b9-8c2d-fb3738dd0f9f hanlla-mgmt-support-hq halla-mgmt-support-hq hanlla-business-support halla-business-support public
40 d656c134-a50b-43b9-8c2d-fb3738dd0f9f 경영지원본부 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-mgmt-support-hq halla-mgmt-support-hq public
41 940cc09c-32f5-4a02-8213-fb02521189d0 영업총괄 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-general-sales halla-general-sales public
42 57496cae-a081-4836-a20e-75c78b62257f 업무총괄 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-general-business halla-general-business public
43 81e94e6c-e27a-4e36-b0f9-bf8823c96493 임원실 ORGANIZATION 5a03efd2-e62f-4243-800d-58334bf48b2f hanlla halla hanlla-executive halla-executive public
44 786dd00c-b0c1-4db9-b25b-1afecd6a7a41 안전관리 ORGANIZATION 94c23f79-a213-4a9e-9c5d-7751777f2fe8 js-construction-hq js-safety-management public
45 5fbf6f2c-6b12-4124-a457-d1064dbb8677 현장 ORGANIZATION 94c23f79-a213-4a9e-9c5d-7751777f2fe8 js-construction-hq js-site public
46 dd82bb7b-43d8-4744-ab65-9b47ea492ac4 공무 ORGANIZATION 94c23f79-a213-4a9e-9c5d-7751777f2fe8 js-construction-hq js-construction-admin public
125 01fcbee1-df33-4ee9-bf2b-6d9eb81917d9 대외협력팀 ORGANIZATION a16f49c4-6828-4fde-a164-43099c4560c4 planning external-relations public
126 cdc40c0b-f985-461a-be18-f8c8e82f31e8 재무회계팀 ORGANIZATION a16f49c4-6828-4fde-a164-43099c4560c4 planning finance public
127 c6aa2133-ded0-451c-b51b-27faa8b56507 PQ팀 ORGANIZATION a16f49c4-6828-4fde-a164-43099c4560c4 planning pq-team public
128 ca54cffe-ad30-4f9e-983a-88a85c70404d 업무팀 ORGANIZATION d656c134-a50b-43b9-8c2d-fb3738dd0f9f hanlla-mgmt-support-hq halla-mgmt-support-hq hanlla-operations halla-operations public
129 a16f49c4-6828-4fde-a164-43099c4560c4 기획부 ORGANIZATION 9bf67270-e15e-4278-b407-02dec5672876 business-strategy planning public
130 9bf67270-e15e-4278-b407-02dec5672876 경영전략본부 ORGANIZATION 9caf62e1-297d-4e8f-870b-61780998bbeb saman business-strategy public
131 896da8ab-50b7-4a63-abbc-c85037b63acc 시공BIM ORGANIZATION 56cd0fd7-b62a-43c0-8db9-74a30468d7cb tdc construction-bim public
179 761a8725-9c19-442c-986c-0319e33a5b1e 총괄기획실 ORGANIZATION 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee gpdtdc gpd public
180 e57cb22c-383e-4489-8c2f-0c5431917e86 PTC COMPANY 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group ptc public
181 9607eb7b-04d2-42ab-80fe-780fe21c7e8f Personal PERSONAL personal 개인 사용자 기본 루트 테넌트 public
182 5a03efd2-e62f-4243-800d-58334bf48b2f 한라산업개발 ORGANIZATION 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group hanlla halla public
183 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 장헌산업 ORGANIZATION 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group jangheon-sanup public
184 c18a8284-0008-48aa-9cdf-9f47ab79a2a9 (주)장헌 ORGANIZATION 96369f12-6b66-4b2a-a916-d1c99d326f02 baron-group jangheon public
185 96369f12-6b66-4b2a-a916-d1c99d326f02 바론그룹 COMPANY_GROUP 038326b6-954a-48a7-a85f-efd83f62b82a hanmac-family baron-group 네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID public

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
cli_config:
dependency: transitive
description:
@@ -268,6 +268,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -320,18 +328,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
@@ -653,26 +661,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.12"
toml:
dependency: "direct main"
description: