forked from baron/baron-sso
Update dev workflow and org chart settings
This commit is contained in:
@@ -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 # 감사 로그 대기열(채널) 버퍼 크기
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", ""))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, UserSummary[]>();
|
||||
for (const user of users) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Set<string>>(
|
||||
@@ -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 (
|
||||
|
||||
@@ -22,7 +22,7 @@ function PickerScenarioControls({
|
||||
onChange: (options: OrgPickerEmbedOptions) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto] lg:items-end">
|
||||
<div className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto,auto] lg:items-end">
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">선택 모드</span>
|
||||
<select
|
||||
@@ -104,6 +104,20 @@ function PickerScenarioControls({
|
||||
<span>하위 선택 스위치 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
|
||||
<input
|
||||
checked={options.includeInternal}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...options,
|
||||
includeInternal: event.target.checked,
|
||||
})
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>internal 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">임베딩 너비</span>
|
||||
<input
|
||||
|
||||
@@ -333,6 +333,7 @@ export function OrgPickerEmbedPage() {
|
||||
const mode = parseOrgPickerMode(searchParams.get("mode"));
|
||||
const select = parseOrgPickerSelectableType(searchParams.get("select"));
|
||||
const rootTenantId = searchParams.get("rootTenantId") || undefined;
|
||||
const includeInternal = searchParams.get("includeInternal") === "true";
|
||||
const tenantId =
|
||||
searchParams.get("tenantSlug") ||
|
||||
searchParams.get("tenantId") ||
|
||||
@@ -363,12 +364,20 @@ export function OrgPickerEmbedPage() {
|
||||
|
||||
const tree = React.useMemo(() => {
|
||||
return buildOrgPickerTree({
|
||||
includeInternal,
|
||||
tenants: tenantsQuery.data?.items ?? [],
|
||||
users: select === "tenant" ? [] : (usersQuery.data?.items ?? []),
|
||||
rootTenantId,
|
||||
tenantId,
|
||||
});
|
||||
}, [rootTenantId, select, tenantId, tenantsQuery.data, usersQuery.data]);
|
||||
}, [
|
||||
includeInternal,
|
||||
rootTenantId,
|
||||
select,
|
||||
tenantId,
|
||||
tenantsQuery.data,
|
||||
usersQuery.data,
|
||||
]);
|
||||
|
||||
const selectedItems = React.useMemo(
|
||||
() =>
|
||||
@@ -579,7 +588,7 @@ export function OrgPickerPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto] lg:items-end">
|
||||
<section className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto,auto] lg:items-end">
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">선택 모드</span>
|
||||
<select
|
||||
@@ -661,6 +670,20 @@ export function OrgPickerPage() {
|
||||
<span>하위 선택 스위치 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
|
||||
<input
|
||||
checked={options.includeInternal}
|
||||
onChange={(event) =>
|
||||
setOptions((current) => ({
|
||||
...current,
|
||||
includeInternal: event.target.checked,
|
||||
}))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>internal 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">임베딩 너비</span>
|
||||
<input
|
||||
|
||||
@@ -17,13 +17,19 @@ do
|
||||
assert_contains "$workflow" "APP_ENV=stage"
|
||||
assert_contains "$workflow" "BACKEND_LOG_LEVEL=debug"
|
||||
assert_contains "$workflow" "CLIENT_LOG_DEBUG=true"
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}'
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}'
|
||||
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}'
|
||||
done
|
||||
|
||||
assert_contains ".gitea/workflows/staging_release.yml" "scp adminfront/seed-tenant.csv"
|
||||
assert_contains "docker-compose.yaml" 'WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL}'
|
||||
assert_contains "docker-compose.yaml" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL}'
|
||||
assert_contains "docker/docker-compose.staging.template.yaml" "SEED_TENANT_CSV_PATH=/app/seed-tenant.csv"
|
||||
assert_contains "docker/docker-compose.staging.template.yaml" "./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" "SEED_TENANT_CSV_PATH=/app/seed-tenant.csv"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" "./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" 'WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL}'
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL}'
|
||||
|
||||
echo "staging workflow env checks passed"
|
||||
|
||||
Reference in New Issue
Block a user