diff --git a/adminfront/hanmac_org.csv b/adminfront/hanmac_org.csv new file mode 100644 index 00000000..09775891 --- /dev/null +++ b/adminfront/hanmac_org.csv @@ -0,0 +1,18 @@ +"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직" +"네이버웍스관리용","2","슈퍼관리자(su-@samaneng.com)","","","su2@hanmaceng.co.kr","N","N","N","Y","","","" +"영업지원","0","","","","t_686ee@hanmaceng.co.kr","Y","N","Y","Y","","","" +"안전관리부","0","","","","t_993iv@hanmaceng.co.kr","Y","N","Y","Y","","","" +"인프라사업본부","0","","","","t_211qf@hanmaceng.co.kr","Y","N","Y","Y","","","" +"도로부","0","","","","t_471lj@hanmaceng.co.kr","Y","N","Y","Y","","","인프라사업본부(t_211qf@hanmaceng.co.kr)" +"교통부","0","","","","t_571hb@hanmaceng.co.kr","Y","N","Y","Y","","","인프라사업본부(t_211qf@hanmaceng.co.kr)" +"구조부","0","","","","t_758pk@hanmaceng.co.kr","Y","N","Y","Y","","","인프라사업본부(t_211qf@hanmaceng.co.kr)" +"지반터널부","0","","","","t_113ok@hanmaceng.co.kr","Y","N","Y","Y","","","인프라사업본부(t_211qf@hanmaceng.co.kr)" +"국토환경사업본부","0","","","","t_892ed@hanmaceng.co.kr","Y","N","Y","Y","","","" +"환경평가부","0","","","","t_021ay@hanmaceng.co.kr","Y","N","Y","Y","","","국토환경사업본부(t_892ed@hanmaceng.co.kr)" +"도시계획부","0","","","","t_501xv@hanmaceng.co.kr","Y","N","Y","Y","","","국토환경사업본부(t_892ed@hanmaceng.co.kr)" +"수자원부","0","","","","t_420zr@hanmaceng.co.kr","Y","N","Y","Y","","","국토환경사업본부(t_892ed@hanmaceng.co.kr)" +"상하수도부","0","","","","t_979ca@hanmaceng.co.kr","Y","N","Y","Y","","","국토환경사업본부(t_892ed@hanmaceng.co.kr)" +"건설사업관리본부","0","","","","t_504fh@hanmaceng.co.kr","Y","N","Y","Y","","","" +"건설사업부","0","","","","t_756my@hanmaceng.co.kr","Y","N","Y","Y","","","건설사업관리본부(t_504fh@hanmaceng.co.kr)" +"안전진단부","0","","","","t_470ta@hanmaceng.co.kr","Y","N","Y","Y","","","" +"경영지원부","0","","","","t_804xh@hanmaceng.co.kr","Y","N","Y","Y","","","" diff --git a/adminfront/hanmac_org_slugged.csv b/adminfront/hanmac_org_slugged.csv new file mode 100644 index 00000000..da63374f --- /dev/null +++ b/adminfront/hanmac_org_slugged.csv @@ -0,0 +1,18 @@ +"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직" +"네이버웍스관리용","2","슈퍼관리자(su-@samaneng.com)","","","nw-admin-hanmac@hanmaceng.co.kr","N","N","N","Y","","","" +"영업지원","0","","","","sales-support@hanmaceng.co.kr","Y","N","Y","Y","","","" +"안전관리부","0","","","","safety-management@hanmaceng.co.kr","Y","N","Y","Y","","","" +"인프라사업본부","0","","","","infrastructure-hq@hanmaceng.co.kr","Y","N","Y","Y","","","" +"도로부","0","","","","infra-road@hanmaceng.co.kr","Y","N","Y","Y","","","인프라사업본부(infrastructure-hq@hanmaceng.co.kr)" +"교통부","0","","","","traffic@hanmaceng.co.kr","Y","N","Y","Y","","","인프라사업본부(infrastructure-hq@hanmaceng.co.kr)" +"구조부","0","","","","infra-structures@hanmaceng.co.kr","Y","N","Y","Y","","","인프라사업본부(infrastructure-hq@hanmaceng.co.kr)" +"지반터널부","0","","","","infra-geotech-tunnel@hanmaceng.co.kr","Y","N","Y","Y","","","인프라사업본부(infrastructure-hq@hanmaceng.co.kr)" +"국토환경사업본부","0","","","","land-environment-hq@hanmaceng.co.kr","Y","N","Y","Y","","","" +"환경평가부","0","","","","land-env-assessment@hanmaceng.co.kr","Y","N","Y","Y","","","국토환경사업본부(land-environment-hq@hanmaceng.co.kr)" +"도시계획부","0","","","","land-env-urban-planning@hanmaceng.co.kr","Y","N","Y","Y","","","국토환경사업본부(land-environment-hq@hanmaceng.co.kr)" +"수자원부","0","","","","land-env-water-resources@hanmaceng.co.kr","Y","N","Y","Y","","","국토환경사업본부(land-environment-hq@hanmaceng.co.kr)" +"상하수도부","0","","","","water-sewerage@hanmaceng.co.kr","Y","N","Y","Y","","","국토환경사업본부(land-environment-hq@hanmaceng.co.kr)" +"건설사업관리본부","0","","","","construction-management-hq@hanmaceng.co.kr","Y","N","Y","Y","","","" +"건설사업부","0","","","","construction-business@hanmaceng.co.kr","Y","N","Y","Y","","","건설사업관리본부(construction-management-hq@hanmaceng.co.kr)" +"안전진단부","0","","","","safety-diagnosis@hanmaceng.co.kr","Y","N","Y","Y","","","" +"경영지원부","0","","","","management-support@hanmaceng.co.kr","Y","N","Y","Y","","","" diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index d892b6a5..cd3cb558 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -80,7 +80,7 @@ import { } from "../utils/tenantCsvImport"; const tenantCSVTemplate = - "name,type,parent_tenant_slug,slug,memo,email_domain\n"; + "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; type SortConfig = { key: keyof TenantSummary | "recursiveMemberCount"; diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts index 9f470834..c70b44dc 100644 --- a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts +++ b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts @@ -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\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n", + "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", ); expect(rows).toEqual([ @@ -80,6 +80,8 @@ describe("tenantCsvImport", () => { slug: "hanmac-tech", memo: "Memo", emailDomain: "hanmac-tech.example.com", + visibility: "internal", + orgUnitType: "센터", }, ]); }); @@ -109,15 +111,18 @@ 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\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n", + "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", ); const preview = buildTenantImportPreview(rows, tenants); const csv = serializeTenantImportCSV(preview, { 2: "tenant-1", }); + expect(csv.split("\n")[0]).toBe( + "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type", + ); expect(csv).toContain( - "tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com", + "tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀", ); }); @@ -228,7 +233,7 @@ describe("tenantCsvImport", () => { }); expect(csv.split("\n")[0]).toBe( - "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain", + "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type", ); expect(csv).toContain( "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,", diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.ts index b8fb64c7..95154cc4 100644 --- a/adminfront/src/features/tenants/utils/tenantCsvImport.ts +++ b/adminfront/src/features/tenants/utils/tenantCsvImport.ts @@ -10,6 +10,8 @@ export type TenantCSVRow = { slug: string; memo: string; emailDomain: string; + visibility: string; + orgUnitType: string; }; export type TenantCSVParseOptions = { @@ -76,6 +78,8 @@ const importHeaders = [ "slug", "memo", "email_domain", + "visibility", + "org_unit_type", ]; const headerAliases: Record = { @@ -102,6 +106,16 @@ const headerAliases: Record = { email_domain: "emailDomain", domain: "emailDomain", domains: "emailDomain", + visibility: "visibility", + public_setting: "visibility", + publicsetting: "visibility", + orgunittype: "orgUnitType", + org_unit_type: "orgUnitType", + "org-unit-type": "orgUnitType", + organizationtype: "orgUnitType", + organization_type: "orgUnitType", + orgtype: "orgUnitType", + org_type: "orgUnitType", }; export function parseTenantCSV( @@ -159,6 +173,8 @@ export function parseTenantCSV( slug, memo: value("memo"), emailDomain: value("emailDomain"), + visibility: value("visibility"), + orgUnitType: value("orgUnitType"), }; }); } @@ -287,6 +303,8 @@ export function serializeTenantImportCSV( slug, preview.row.memo, preview.row.emailDomain, + preview.row.visibility, + preview.row.orgUnitType, ]); } return `${lines.map(formatCSVRecord).join("\n")}\n`; diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 4b07a58c..ea061876 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -110,6 +110,8 @@ type tenantCSVRecord struct { Slug string Memo string Domains []string + Visibility string + OrgUnitType string } func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error { @@ -278,10 +280,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"}); 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"}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - } else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain"}); err != nil { + } else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type"}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } slugByID := make(map[string]string, len(tenants)) @@ -302,6 +304,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { domains = append(domains, domainName) } } + visibility, orgUnitType := tenantCSVOrgConfigValues(tenant.Config) row := []string{ tenant.Name, tenant.Type, @@ -309,6 +312,8 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { tenant.Slug, tenant.Description, strings.Join(domains, ";"), + visibility, + orgUnitType, } if includeIDs { row = []string{ @@ -320,6 +325,8 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { tenant.Slug, tenant.Description, strings.Join(domains, ";"), + visibility, + orgUnitType, } } if err := writer.Write(row); err != nil { @@ -501,6 +508,8 @@ func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) { 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"), }) } @@ -529,6 +538,16 @@ func tenantCSVHeaderIndex(header []string) map[string]int { "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", } for i, column := range header { key := strings.ToLower(strings.TrimSpace(column)) @@ -745,6 +764,45 @@ func tenantVisibility(config domain.JSONMap) string { } } +func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string) { + visibility := tenantVisibility(config) + orgUnitType, _ := config["orgUnitType"].(string) + return visibility, strings.TrimSpace(orgUnitType) +} + +func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) { + config := map[string]any{} + if strings.TrimSpace(record.Visibility) != "" { + config["visibility"] = record.Visibility + } + if strings.TrimSpace(record.OrgUnitType) != "" { + config["orgUnitType"] = record.OrgUnitType + } + if len(config) == 0 { + return nil, nil + } + return normalizeTenantConfig(config) +} + +func mergeTenantCSVRecordConfig(current domain.JSONMap, record tenantCSVRecord) (domain.JSONMap, bool, error) { + recordConfig, err := tenantCSVRecordConfig(record) + if err != nil { + return nil, false, err + } + if len(recordConfig) == 0 { + return current, false, nil + } + + merged := make(domain.JSONMap, len(current)+len(recordConfig)) + for key, value := range current { + merged[key] = value + } + for key, value := range recordConfig { + merged[key] = value + } + return merged, true, nil +} + func filterPublicTenants(tenants []domain.Tenant) []domain.Tenant { excludedIDs := make(map[string]bool) for _, tenant := range tenants { @@ -963,6 +1021,13 @@ func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco if tenant.Status == "" { tenant.Status = domain.TenantStatusActive } + mergedConfig, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record) + if err != nil { + return nil, false, err + } + if changedConfig { + tenant.Config = mergedConfig + } if err := h.DB.Save(&tenant).Error; err != nil { return nil, false, err @@ -999,6 +1064,13 @@ func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco Description: record.Memo, Status: domain.TenantStatusActive, } + config, _, err := mergeTenantCSVRecordConfig(nil, record) + if err != nil { + return nil, err + } + if len(config) > 0 { + tenant.Config = config + } if err := h.DB.Create(&tenant).Error; err != nil { return nil, err } @@ -1041,6 +1113,22 @@ func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVReco } tenant, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID) + if err != nil || tenant == nil { + return tenant, err + } + config, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record) + if err != nil { + return nil, err + } + if changedConfig { + if h.DB == nil { + return nil, errors.New("database not available for tenant config import") + } + tenant.Config = config + if err := h.DB.Save(tenant).Error; err != nil { + return nil, err + } + } return tenant, err } diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 2b030380..69989e64 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -454,6 +454,10 @@ func TestTenantHandler_ExportTenantsCSV(t *testing.T) { ParentID: &parentID, Slug: "tenant-a", Description: "Primary tenant", + Config: domain.JSONMap{ + "visibility": "internal", + "orgUnitType": "센터", + }, Domains: []domain.TenantDomain{ {Domain: "tenant-a.example.com"}, {Domain: "login.tenant-a.example.com"}, @@ -470,8 +474,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") - assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com") + 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,센터") } func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) { @@ -506,7 +510,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") + assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type") 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") @@ -663,13 +667,15 @@ func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) { func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) { records, err := parseTenantCSVRecords(strings.NewReader( - "name,type,parent_tenant_slug,slug,memo,email_domain\n" + - "Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\"\n", + "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", )) assert.NoError(t, err) assert.Len(t, records, 1) 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) } func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) { diff --git a/orgfront/src/features/auth/LoginPage.test.tsx b/orgfront/src/features/auth/LoginPage.test.tsx new file mode 100644 index 00000000..12500838 --- /dev/null +++ b/orgfront/src/features/auth/LoginPage.test.tsx @@ -0,0 +1,73 @@ +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import LoginPage from "./LoginPage"; + +const authState = { + isAuthenticated: false, + isLoading: false, + activeNavigator: undefined as string | undefined, + signinRedirect: vi.fn(), +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +function renderLoginPage(initialEntry: string) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + return { container, root }; +} + +function cleanupRendered(container: HTMLDivElement, root: Root) { + act(() => { + root.unmount(); + }); + container.remove(); +} + +describe("OrgFront LoginPage auto login", () => { + afterEach(() => { + vi.clearAllMocks(); + authState.isAuthenticated = false; + authState.isLoading = false; + authState.activeNavigator = undefined; + }); + + it("does not start auto login again when the orgfront session is already authenticated", () => { + authState.isAuthenticated = true; + + const rendered = renderLoginPage( + "/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle", + ); + + expect(authState.signinRedirect).not.toHaveBeenCalled(); + cleanupRendered(rendered.container, rendered.root); + }); + + it("starts auto login once when auto mode is requested without an authenticated session", () => { + const rendered = renderLoginPage( + "/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle", + ); + + expect(authState.signinRedirect).toHaveBeenCalledTimes(1); + expect(authState.signinRedirect).toHaveBeenCalledWith({ + state: { + returnTo: "/embed/picker?mode=single", + }, + }); + cleanupRendered(rendered.container, rendered.root); + }); +}); diff --git a/orgfront/src/features/auth/LoginPage.tsx b/orgfront/src/features/auth/LoginPage.tsx index 477a764e..54fc70e2 100644 --- a/orgfront/src/features/auth/LoginPage.tsx +++ b/orgfront/src/features/auth/LoginPage.tsx @@ -30,7 +30,12 @@ function LoginPage() { if (!shouldAutoLogin) { return; } - if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) { + if ( + auth.isAuthenticated || + autoStartedRef.current || + auth.isLoading || + auth.activeNavigator + ) { return; } @@ -40,7 +45,14 @@ function LoginPage() { returnTo, }, }); - }, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]); + }, [ + auth, + auth.activeNavigator, + auth.isAuthenticated, + auth.isLoading, + returnTo, + shouldAutoLogin, + ]); const handleSSOLogin = async () => { try {