From 2c3cab78b12192a40b913d17cb48ab6984b7b1a7 Mon Sep 17 00:00:00 2001 From: Lectom Date: Wed, 20 May 2026 18:15:54 +0900 Subject: [PATCH] Update dev workflow and org chart settings --- .env.sample | 5 ++ .gitea/workflows/staging_code_pull.yml | 2 + .gitea/workflows/staging_release.yml | 2 + .../src/components/layout/AppLayout.tsx | 1 + .../src/features/users/orgChartPicker.test.ts | 24 +++++++- .../src/features/users/orgChartPicker.ts | 20 +++++- backend/cmd/server/main.go | 9 +++ backend/cmd/server/worksmobile_config_test.go | 12 ++++ .../internal/service/worksmobile_client.go | 42 ++++++++----- .../service/worksmobile_client_test.go | 61 ++++++++++++++++--- .../service/worksmobile_live_flow_test.go | 48 ++------------- docker-compose.yaml | 2 + docker/staging_pull_compose.template.yaml | 2 + .../src/features/orgchart/pickerTree.test.ts | 42 ++++++++++++- orgfront/src/features/orgchart/pickerTree.ts | 4 +- .../src/features/orgchart/pickerTypes.test.ts | 22 +++++++ orgfront/src/features/orgchart/pickerTypes.ts | 5 ++ .../features/orgchart/routes/OrgChartPage.tsx | 12 +++- .../routes/OrgPickerEmbedPreviewPage.tsx | 16 ++++- .../orgchart/routes/OrgPickerPage.tsx | 27 +++++++- scripts/test_staging_workflow_env.sh | 6 ++ 21 files changed, 288 insertions(+), 76 deletions(-) diff --git a/.env.sample b/.env.sample index 2cc41e84..6960fbc3 100644 --- a/.env.sample +++ b/.env.sample @@ -32,6 +32,11 @@ BACKEND_LOG_LEVEL= REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준) CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요 +# --- NAVER WORKS API --- +WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com +WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token + + # Audit System Configuration AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수 AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기 diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 366679e7..e051132e 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -48,6 +48,8 @@ jobs: APP_ENV=stage BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true + WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }} + WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }} TZ=Asia/Seoul IDP_PROVIDER=ory diff --git a/.gitea/workflows/staging_release.yml b/.gitea/workflows/staging_release.yml index d3e63209..8edfa7a3 100644 --- a/.gitea/workflows/staging_release.yml +++ b/.gitea/workflows/staging_release.yml @@ -58,6 +58,8 @@ jobs: APP_ENV=stage BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true + WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }} + WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }} TZ=Asia/Seoul IDP_PROVIDER=ory diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 2c18e121..3795f6e7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -184,6 +184,7 @@ function AppLayout() { const manageableCount = profile?.manageableTenants?.length ?? 0; const orgfrontUrl = buildAuthenticatedOrgChartUrl( import.meta.env.ORGFRONT_URL || "http://localhost:5175", + { includeInternal: true }, ); const filteredItems = items.filter((item) => { diff --git a/adminfront/src/features/users/orgChartPicker.test.ts b/adminfront/src/features/users/orgChartPicker.test.ts index 95ca5673..0fa4e65c 100644 --- a/adminfront/src/features/users/orgChartPicker.test.ts +++ b/adminfront/src/features/users/orgChartPicker.test.ts @@ -15,6 +15,16 @@ describe("orgChartPicker", () => { ); }); + it("adds internal visibility to tenant picker URLs only when requested", () => { + expect( + buildOrgChartTenantPickerUrl("https://orgchart.example.com/", { + includeInternal: true, + }), + ).toBe( + "https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&includeInternal=true", + ); + }); + it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => { expect( buildOrgChartTenantPickerUrl("https://orgchart.example.com/", { @@ -34,12 +44,22 @@ describe("orgChartPicker", () => { }, ), ).toBe( - "https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id", + "https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue", ); }); - it("builds the chart navigation URL through the org-chart auto login entry", () => { + it("builds the admin chart navigation URL with internal visibility enabled", () => { expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe( + "https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue", + ); + }); + + it("can build chart navigation URL without internal visibility", () => { + expect( + buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", { + includeInternal: false, + }), + ).toBe( "https://orgchart.example.com/login?auto=1&returnTo=%2Fchart", ); }); diff --git a/adminfront/src/features/users/orgChartPicker.ts b/adminfront/src/features/users/orgChartPicker.ts index 05b7a511..7d8cbf2d 100644 --- a/adminfront/src/features/users/orgChartPicker.ts +++ b/adminfront/src/features/users/orgChartPicker.ts @@ -34,10 +34,12 @@ type OrgChartPickerMessage = { }; type OrgChartTenantPickerOptions = { + includeInternal?: boolean; tenantId?: string; }; type OrgChartLoginOptions = { + includeInternal?: boolean; returnTo?: string; }; @@ -204,6 +206,9 @@ export function buildOrgChartTenantPickerUrl( if (tenantId) { params.set("tenantId", tenantId); } + if (options.includeInternal) { + params.set("includeInternal", "true"); + } return `${normalizedBase}/embed/picker?${params.toString()}`; } @@ -212,16 +217,25 @@ export function buildAuthenticatedOrgChartTenantPickerUrl( baseUrl?: string, options: OrgChartTenantPickerOptions = {}, ) { - const pickerUrl = buildOrgChartTenantPickerUrl("", options); + const pickerUrl = buildOrgChartTenantPickerUrl("", { + includeInternal: true, + ...options, + }); return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl }); } export function buildAuthenticatedOrgChartUrl( baseUrl?: string, - options: OrgChartLoginOptions = {}, + options: OrgChartLoginOptions = { includeInternal: true }, ) { const normalizedBase = (baseUrl ?? "").replace(/\/+$/, ""); - const returnTo = options.returnTo?.trim() || "/chart"; + let returnTo = options.returnTo?.trim() || "/chart"; + if (options.includeInternal && returnTo.startsWith("/chart")) { + const [path, query = ""] = returnTo.split("?", 2); + const params = new URLSearchParams(query); + params.set("includeInternal", "true"); + returnTo = `${path}?${params.toString()}`; + } const params = new URLSearchParams({ auto: "1", returnTo, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b5f7b039..f94a7e72 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -331,8 +331,10 @@ func main() { ServiceAccount: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT", ""), PrivateKey: worksmobilePrivateKey, Scope: getEnv("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + TokenURL: getEnv("WORKS_ADMIN_OAUTH_TOKEN_URL", ""), }, ) + configureWorksmobileClientFromEnv(worksmobileClient) worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient) worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient) go worksmobileRelayWorker.Start(context.Background()) @@ -898,3 +900,10 @@ func main() { os.Exit(1) } } + +func configureWorksmobileClientFromEnv(client *service.WorksmobileHTTPClient) { + if client == nil { + return + } + client.BaseURL = strings.TrimSpace(getEnv("WORKS_ADMIN_API_BASE_URL", "")) +} diff --git a/backend/cmd/server/worksmobile_config_test.go b/backend/cmd/server/worksmobile_config_test.go index 9dccc1c9..4646344c 100644 --- a/backend/cmd/server/worksmobile_config_test.go +++ b/backend/cmd/server/worksmobile_config_test.go @@ -1,6 +1,7 @@ package main import ( + "baron-sso-backend/internal/service" "os" "path/filepath" "testing" @@ -37,3 +38,14 @@ func TestGetEnvFileOrValueFallsBackToRawEnv(t *testing.T) { t.Fatalf("secret value = %q, want raw env value", got) } } + +func TestConfigureWorksmobileClientFromEnvOverridesAPIBaseURL(t *testing.T) { + t.Setenv("WORKS_ADMIN_API_BASE_URL", "https://proxy.example.com/works") + client := service.NewWorksmobileHTTPClientWithTokens("", "") + + configureWorksmobileClientFromEnv(client) + + if client.BaseURL != "https://proxy.example.com/works" { + t.Fatalf("BaseURL = %q, want env override", client.BaseURL) + } +} diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go index 910bc512..68888573 100644 --- a/backend/internal/service/worksmobile_client.go +++ b/backend/internal/service/worksmobile_client.go @@ -21,9 +21,7 @@ import ( ) const ( - defaultWorksmobileAPIBaseURL = "https://www.worksapis.com" - defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token" - defaultWorksmobileOAuthScope = "directory" + defaultWorksmobileOAuthScope = "directory" ) type WorksmobileDirectoryClient interface { @@ -74,9 +72,6 @@ func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig { c.Scope = defaultWorksmobileOAuthScope } c.TokenURL = strings.TrimSpace(c.TokenURL) - if c.TokenURL == "" { - c.TokenURL = defaultWorksmobileOAuthTokenURL - } return c } @@ -87,6 +82,9 @@ func (c WorksmobileOAuthConfig) validate() error { if strings.TrimSpace(c.ServiceAccount) == "" || strings.TrimSpace(c.PrivateKey) == "" { return fmt.Errorf("worksmobile oauth service account is not configured") } + if strings.TrimSpace(c.TokenURL) == "" { + return fmt.Errorf("worksmobile oauth token url is not configured") + } return nil } @@ -163,14 +161,12 @@ func (e WorksmobileHTTPError) Error() string { func NewWorksmobileHTTPClient(scimToken string) *WorksmobileHTTPClient { return &WorksmobileHTTPClient{ - BaseURL: defaultWorksmobileAPIBaseURL, SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), } } func NewWorksmobileHTTPClientWithTokens(directoryToken string, scimToken string) *WorksmobileHTTPClient { return &WorksmobileHTTPClient{ - BaseURL: defaultWorksmobileAPIBaseURL, DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`), SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), } @@ -178,7 +174,6 @@ func NewWorksmobileHTTPClientWithTokens(directoryToken string, scimToken string) func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, oauthConfig WorksmobileOAuthConfig) *WorksmobileHTTPClient { return &WorksmobileHTTPClient{ - BaseURL: defaultWorksmobileAPIBaseURL, DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`), SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), OAuthConfig: oauthConfig.normalized(), @@ -437,7 +432,11 @@ func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target if token == "" { return fmt.Errorf("worksmobile read token is not configured") } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.baseURL(), "/")+path, nil) + requestURL, err := c.requestURL(path) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return err } @@ -461,7 +460,11 @@ func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path strin if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.baseURL(), "/")+path, nil) + requestURL, err := c.requestURL(path) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return err } @@ -672,7 +675,11 @@ func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method st body = bytes.NewReader(data) } - req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.baseURL(), "/")+path, body) + requestURL, err := c.requestURL(path) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, method, requestURL, body) if err != nil { return err } @@ -1117,12 +1124,17 @@ func boolPointerFromMap(values map[string]any, keys ...string) *bool { } func (c *WorksmobileHTTPClient) baseURL() string { - if strings.TrimSpace(c.BaseURL) == "" { - return defaultWorksmobileAPIBaseURL - } return c.BaseURL } +func (c *WorksmobileHTTPClient) requestURL(path string) (string, error) { + baseURL := strings.TrimSpace(c.baseURL()) + if baseURL == "" { + return "", fmt.Errorf("worksmobile api base url is not configured") + } + return strings.TrimRight(baseURL, "/") + path, nil +} + func (c *WorksmobileHTTPClient) httpClient() *http.Client { if c.HTTPClient != nil { return c.HTTPClient diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go index 6d6c0bf6..c30d96f2 100644 --- a/backend/internal/service/worksmobile_client_test.go +++ b/backend/internal/service/worksmobile_client_test.go @@ -181,6 +181,46 @@ func TestWorksmobileHTTPClientRequestsJWTBearerAccessToken(t *testing.T) { require.Equal(t, float64(1710003600), payload["exp"]) } +func TestWorksmobileHTTPClientRequiresConfiguredAPIBaseURL(t *testing.T) { + client := &WorksmobileHTTPClient{ + DirectoryToken: "directory-token-1", + HTTPClient: &http.Client{Transport: &captureRoundTripper{statusCode: http.StatusCreated, body: `{}`}}, + } + + err := client.CreateUser(context.Background(), WorksmobileUserPayload{ + DomainID: 300285955, + Email: "tester@samaneng.com", + UserExternalKey: "user-1", + UserName: WorksmobileUserName{LastName: "Tester"}, + PasswordConfig: WorksmobilePasswordConfig{PasswordCreationType: "ADMIN", Password: "Aa1!Aa1!Aa1!Aa1!"}, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "worksmobile api base url is not configured") +} + +func TestWorksmobileHTTPClientRequiresConfiguredOAuthTokenURL(t *testing.T) { + privateKey := testRSAPrivateKeyPEM(t) + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + OAuthConfig: WorksmobileOAuthConfig{ + ClientID: "client-id-1", + ClientSecret: "client-secret-1", + ServiceAccount: "service-account-1", + PrivateKey: privateKey, + Scope: "directory", + }, + } + + _, _, err := client.requestDirectoryAccessToken( + context.Background(), + time.Unix(1710000000, 0), + ) + + require.Error(t, err) + require.Contains(t, err.Error(), "worksmobile oauth token url is not configured") +} + func TestWorksmobileHTTPClientListUsersUsesDirectoryAPIFirst(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "300285955") transport := &captureRoundTripper{ @@ -393,13 +433,7 @@ func TestWorksmobileLiveJWTTokenExchange(t *testing.T) { if os.Getenv("WORKSMOBILE_LIVE_JWT_TOKEN_EXCHANGE") != "1" { t.Skip("live Worksmobile token exchange is disabled") } - client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ - ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), - ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), - PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), - Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), - }) + client := newWorksmobileLiveClient() token, err := client.directoryAccessToken(context.Background()) @@ -989,6 +1023,19 @@ func getenvDefault(key string, fallback string) string { return fallback } +func newWorksmobileLiveClient() *WorksmobileHTTPClient { + client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ + ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), + ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), + PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), + Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + TokenURL: os.Getenv("WORKS_ADMIN_OAUTH_TOKEN_URL"), + }) + client.BaseURL = os.Getenv("WORKS_ADMIN_API_BASE_URL") + return client +} + func (f *fakeWorksmobileDirectoryClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error { f.createdOrgUnits = append(f.createdOrgUnits, payload) return nil diff --git a/backend/internal/service/worksmobile_live_flow_test.go b/backend/internal/service/worksmobile_live_flow_test.go index 269ffbb0..c06b3570 100644 --- a/backend/internal/service/worksmobile_live_flow_test.go +++ b/backend/internal/service/worksmobile_live_flow_test.go @@ -33,13 +33,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) { userGroupRepo := repository.NewUserGroupRepository(db) outboxRepo := repository.NewWorksmobileOutboxRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) - client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ - ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), - ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), - PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), - Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), - }) + client := newWorksmobileLiveClient() syncService := NewWorksmobileSyncService(tenantService, userRepo, outboxRepo, client) worker := NewWorksmobileRelayWorker(outboxRepo, client) @@ -145,13 +139,7 @@ func TestWorksmobileLiveSyncHanmacFamilyOrgUnits(t *testing.T) { userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) - client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ - ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), - ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), - PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), - Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), - }) + client := newWorksmobileLiveClient() root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug) require.NoError(t, err) @@ -239,13 +227,7 @@ func TestWorksmobileLiveInspectGPDTDCOrgUnits(t *testing.T) { userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) - client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ - ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), - ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), - PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), - Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), - }) + client := newWorksmobileLiveClient() gpdtdcTenant, err := tenantService.GetTenantBySlug(ctx, "gpdtdc") require.NoError(t, err) @@ -322,13 +304,7 @@ func TestWorksmobileLiveRecoverGPDTDCOrgUnits(t *testing.T) { userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) - client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ - ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), - ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), - PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), - Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), - }) + client := newWorksmobileLiveClient() gpdtdcTenant, err := tenantService.GetTenantBySlug(ctx, "gpdtdc") require.NoError(t, err) @@ -502,13 +478,7 @@ func runWorksmobileLiveCompanyOrgUnitProvisioning(t *testing.T, companySlug stri userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) - client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ - ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), - ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), - PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), - Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), - }) + client := newWorksmobileLiveClient() companyTenant, err := tenantService.GetTenantBySlug(ctx, companySlug) require.NoError(t, err) @@ -565,13 +535,7 @@ func runWorksmobileLiveBaronGroupOrgUnitProvisioning(t *testing.T) { userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) - client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ - ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), - ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), - PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), - Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), - }) + client := newWorksmobileLiveClient() baronGroup, err := tenantService.GetTenantBySlug(ctx, "baron-group") require.NoError(t, err) diff --git a/docker-compose.yaml b/docker-compose.yaml index a36785ea..889ac824 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,6 +11,8 @@ services: - GO_ENV=${APP_ENV:-development} - BACKEND_LOG_LEVEL=${BACKEND_LOG_LEVEL:-info} - CLIENT_LOG_DEBUG=${CLIENT_LOG_DEBUG:-false} + - WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL} + - WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL} - COOKIE_SECRET=${COOKIE_SECRET} - JWT_SECRET=${JWT_SECRET} - NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY} diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index 21c75d05..efea8aff 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -374,6 +374,8 @@ services: environment: - APP_ENV=${APP_ENV:-development} - GO_ENV=${APP_ENV:-development} + - WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL} + - WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL} - COOKIE_SECRET=${COOKIE_SECRET} - JWT_SECRET=${JWT_SECRET} - NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY} diff --git a/orgfront/src/features/orgchart/pickerTree.test.ts b/orgfront/src/features/orgchart/pickerTree.test.ts index c1613a31..5d30a618 100644 --- a/orgfront/src/features/orgchart/pickerTree.test.ts +++ b/orgfront/src/features/orgchart/pickerTree.test.ts @@ -106,10 +106,20 @@ describe("buildOrgPickerTree", () => { ]); }); - it("excludes private tenants and their descendants from picker choices", () => { + 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", @@ -138,4 +148,34 @@ describe("buildOrgPickerTree", () => { 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"), + ]; + + 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", + ]); + }); }); diff --git a/orgfront/src/features/orgchart/pickerTree.ts b/orgfront/src/features/orgchart/pickerTree.ts index 7aa12922..883e1224 100644 --- a/orgfront/src/features/orgchart/pickerTree.ts +++ b/orgfront/src/features/orgchart/pickerTree.ts @@ -108,11 +108,13 @@ function findTenantNode( } export function buildOrgPickerTree({ + includeInternal = false, tenants, users, rootTenantId, tenantId, }: { + includeInternal?: boolean; tenants: TenantSummary[]; users: UserSummary[]; rootTenantId?: string; @@ -120,7 +122,7 @@ export function buildOrgPickerTree({ }) { const visibleTenants = filterTenantsByVisibility( tenants.filter(isOrgFrontTenantType), - "internal", + includeInternal ? "internal" : "public", ); const usersBySlug = new Map(); for (const user of users) { diff --git a/orgfront/src/features/orgchart/pickerTypes.test.ts b/orgfront/src/features/orgchart/pickerTypes.test.ts index dfce2dc7..f91ce16d 100644 --- a/orgfront/src/features/orgchart/pickerTypes.test.ts +++ b/orgfront/src/features/orgchart/pickerTypes.test.ts @@ -8,6 +8,7 @@ describe("org picker embed options", () => { it("builds slug-based tenant scope urls", () => { expect( buildOrgPickerEmbedSrc({ + includeInternal: false, mode: "single", select: "tenant", includeDescendants: true, @@ -21,6 +22,27 @@ describe("org picker embed options", () => { ); }); + it("builds and parses internal visibility flag only when requested", () => { + const src = buildOrgPickerEmbedSrc({ + includeInternal: true, + mode: "single", + select: "tenant", + includeDescendants: true, + showDescendantToggle: true, + tenantId: "", + width: 400, + height: 600, + }); + + expect(src).toBe( + "/embed/picker?mode=single&select=tenant&width=400&height=600&includeInternal=true", + ); + expect(parseOrgPickerEmbedOptions(src).includeInternal).toBe(true); + expect(parseOrgPickerEmbedOptions("?mode=single").includeInternal).toBe( + false, + ); + }); + it("parses tenantSlug first and keeps legacy tenantId compatibility", () => { expect( parseOrgPickerEmbedOptions( diff --git a/orgfront/src/features/orgchart/pickerTypes.ts b/orgfront/src/features/orgchart/pickerTypes.ts index 9463a3fc..1b14f9eb 100644 --- a/orgfront/src/features/orgchart/pickerTypes.ts +++ b/orgfront/src/features/orgchart/pickerTypes.ts @@ -16,6 +16,7 @@ export type OrgPickerResult = { }; export type OrgPickerEmbedOptions = { + includeInternal: boolean; mode: OrgPickerMode; select: OrgPickerSelectableType; includeDescendants: boolean; @@ -68,6 +69,7 @@ export function parseOrgPickerEmbedOptions(search: string) { ? ("single" as const) : ("multiple" as const), select: parseOrgPickerSelectableType(params.get("select")), + includeInternal: params.get("includeInternal") === "true", includeDescendants: params.get("includeDescendants") !== "false", showDescendantToggle: params.get("showDescendantToggle") !== "false", tenantId: @@ -87,6 +89,9 @@ export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) { width: String(options.width), height: String(options.height), }); + if (options.includeInternal) { + params.set("includeInternal", "true"); + } const tenantSlug = options.tenantId.trim(); if (tenantSlug) { diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index 6c33188e..963fb2aa 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -1200,6 +1200,8 @@ export function TenantOrgChartPage() { const location = useLocation(); const searchParams = new URLSearchParams(location.search); const shareToken = searchParams.get("token"); + const visibilityMode = + searchParams.get("includeInternal") === "true" ? "internal" : "public"; const [selectedTenantFilter, setSelectedTenantFilter] = React.useState(FAMILY_FILTER_ID); const [collapsedIds, setCollapsedIds] = React.useState>( @@ -1270,7 +1272,7 @@ export function TenantOrgChartPage() { } const rootNodes = buildTenantFullTree( - filterSystemGlobalTenants(tenantsQuery.data.items, "internal"), + filterSystemGlobalTenants(tenantsQuery.data.items, visibilityMode), ).subTree; return { @@ -1280,7 +1282,13 @@ export function TenantOrgChartPage() { }), sharedWith: "", }; - }, [publicQuery.data, shareToken, tenantsQuery.data, usersQuery.data]); + }, [ + publicQuery.data, + shareToken, + tenantsQuery.data, + usersQuery.data, + visibilityMode, + ]); const familyRoot = React.useMemo(() => { return ( diff --git a/orgfront/src/features/orgchart/routes/OrgPickerEmbedPreviewPage.tsx b/orgfront/src/features/orgchart/routes/OrgPickerEmbedPreviewPage.tsx index 9b27e5e5..55ca1182 100644 --- a/orgfront/src/features/orgchart/routes/OrgPickerEmbedPreviewPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgPickerEmbedPreviewPage.tsx @@ -22,7 +22,7 @@ function PickerScenarioControls({ onChange: (options: OrgPickerEmbedOptions) => void; }) { return ( -
+
+
-
+
+