diff --git a/Makefile b/Makefile index df61eac0..78bf47fd 100644 --- a/Makefile +++ b/Makefile @@ -190,7 +190,7 @@ PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATI PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi' PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi' -.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests +.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-orgfront-tests code-check-userfront-e2e-tests CODE_CHECK_TEST_JOBS ?= 1 PLAYWRIGHT_WORKERS ?= 1 @@ -208,7 +208,8 @@ code-check-test-jobs: code-check-userfront-tests \ code-check-userfront-e2e-tests \ code-check-adminfront-tests \ - code-check-devfront-tests + code-check-devfront-tests \ + code-check-orgfront-tests code-check-i18n: @echo "==> i18n resource check" @@ -263,6 +264,11 @@ code-check-front-lint: cd devfront && npm ci --ignore-scripts cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false + @echo "==> orgfront biome lint/format check" + rm -rf orgfront/playwright-report orgfront/test-results + cd orgfront && npm ci --ignore-scripts + cd orgfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false + cd orgfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false code-check-backend-tests: @echo "==> backend tests" @@ -310,6 +316,22 @@ code-check-devfront-tests: [ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \ exit $$status +code-check-orgfront-tests: + @echo "==> orgfront tests" + @mkdir -p reports/orgfront + @rm -rf reports/orgfront/playwright-report reports/orgfront/test-results + @status=0; \ + (cd orgfront && npm ci --ignore-scripts) || status=$$?; \ + if [ $$status -eq 0 ]; then \ + (cd orgfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \ + fi; \ + if [ $$status -eq 0 ]; then \ + (cd orgfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \ + fi; \ + [ -d orgfront/playwright-report ] && cp -R orgfront/playwright-report reports/orgfront/ || true; \ + [ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \ + exit $$status + code-check-userfront-e2e-tests: @echo "==> userfront wasm playwright e2e tests (isolated workspace)" @mkdir -p reports/userfront-e2e diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx index 654483e4..ca813e96 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -641,7 +641,12 @@ export function buildWorksmobilePasswordManageUrl({ }) { const normalizedTenantId = tenantId?.trim(); const normalizedUserIdNo = userIdNo?.trim(); - if (!normalizedTenantId || !domainId || domainId <= 0 || !normalizedUserIdNo) { + if ( + !normalizedTenantId || + !domainId || + domainId <= 0 || + !normalizedUserIdNo + ) { return ""; } const url = new URL("https://auth.worksmobile.com/integrate/password/manage"); @@ -790,10 +795,7 @@ function ComparisonTable({ .filter(canSelectWorksmobileRow) .map(getWorksmobileRowSelectionKey) .filter(Boolean); - const selectedActionIds = getWorksmobileSelectedActionIds( - rows, - selectedKeys, - ); + const selectedActionIds = getWorksmobileSelectedActionIds(rows, selectedKeys); const allSelectableSelected = selectableKeys.length > 0 && selectableKeys.every((key) => selectedKeys.includes(key)); diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index bd293614..f676ae66 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -462,7 +462,8 @@ test.describe("User Management", () => { "John Doe john@test.com 010-1111-2222", ); - await page.getByTestId("user-status-toggle-u-1").click(); + await page.getByTestId("user-status-select-u-1").click(); + await page.getByRole("option", { name: /비활성|inactive/i }).click(); await expect .poll(() => updatePayload) .toMatchObject({ status: "inactive" }); @@ -816,22 +817,27 @@ test.describe("User Management", () => { (form as HTMLFormElement).requestSubmit(); }); - await expect.poll(() => updatePayload).toMatchObject({ - tenantSlug: "hanmac-team", - primaryTenantId: "hanmac-team-id", - primaryTenantName: "한맥팀", - primaryTenantIsOwner: true, - metadata: { + await expect + .poll(() => updatePayload) + .toMatchObject({ + tenantSlug: "hanmac-team", primaryTenantId: "hanmac-team-id", primaryTenantName: "한맥팀", - primaryTenantSlug: "hanmac-team", primaryTenantIsOwner: true, - additionalAppointments: [ - { tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", isPrimary: false }, - { tenantId: "hanmac-team-id", isPrimary: true }, - ], - }, - }); + metadata: { + primaryTenantId: "hanmac-team-id", + primaryTenantName: "한맥팀", + primaryTenantSlug: "hanmac-team", + primaryTenantIsOwner: true, + additionalAppointments: [ + { + tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", + isPrimary: false, + }, + { tenantId: "hanmac-team-id", isPrimary: true }, + ], + }, + }); }); test("should show conflict error when creating with an existing Login ID", async ({ diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts index dd36795e..294b5dc7 100644 --- a/adminfront/tests/worksmobile.spec.ts +++ b/adminfront/tests/worksmobile.spec.ts @@ -211,11 +211,9 @@ test.describe("Worksmobile tenant management", () => { name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/, }) .allTextContents(); - await expect.poll(() => filterButtons).toEqual([ - "바론에만 있음", - "웍스에만 있음", - "양쪽 다 있음", - ]); + await expect + .poll(() => filterButtons) + .toEqual(["바론에만 있음", "웍스에만 있음", "양쪽 다 있음"]); await page.getByRole("button", { name: "웍스에만 있음" }).click(); await expect(page.getByText("박웍스")).not.toBeVisible(); @@ -481,16 +479,17 @@ test.describe("Worksmobile tenant management", () => { Math.max(pageOverflow.documentScrollWidth, pageOverflow.bodyScrollWidth), ).toBeLessThanOrEqual(pageOverflow.viewportWidth + 1); - const userTableScroll = await page.locator("table").first().evaluate( - (table) => { + const userTableScroll = await page + .locator("table") + .first() + .evaluate((table) => { const container = table.parentElement?.parentElement as HTMLElement; return { clientWidth: container.clientWidth, overflowX: window.getComputedStyle(container).overflowX, scrollWidth: container.scrollWidth, }; - }, - ); + }); expect(userTableScroll.overflowX).toBe("auto"); expect(userTableScroll.scrollWidth).toBeGreaterThan( userTableScroll.clientWidth, diff --git a/backend/internal/bootstrap/admin_account_test.go b/backend/internal/bootstrap/admin_account_test.go index b1b0b4dc..38983d9d 100644 --- a/backend/internal/bootstrap/admin_account_test.go +++ b/backend/internal/bootstrap/admin_account_test.go @@ -18,7 +18,6 @@ func TestEnsureSuperAdminCreatesIdentityLocalUserAndKetoRelation(t *testing.T) { Name: "New Admin", Source: "test", }) - if err != nil { t.Fatalf("EnsureSuperAdmin returned error: %v", err) } @@ -67,7 +66,6 @@ func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) { Name: "Existing Admin", Source: "test", }) - if err != nil { t.Fatalf("EnsureSuperAdmin returned error: %v", err) } diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 0b32a39a..e64e2e61 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "net/url" "os" @@ -2749,16 +2750,35 @@ func validateBackchannelLogoutURI(raw string) error { case "https": return nil case "http": - host := strings.ToLower(parsed.Hostname()) - if host == "localhost" || host == "127.0.0.1" { + if isAllowedLocalBackchannelLogoutHost(parsed.Hostname()) { return nil } - return fmt.Errorf("backchannelLogoutUri must use https outside localhost development") + return fmt.Errorf("backchannelLogoutUri must use https outside local development") default: return fmt.Errorf("backchannelLogoutUri must use http or https") } } +func isAllowedLocalBackchannelLogoutHost(rawHost string) bool { + host := strings.ToLower(strings.TrimSpace(rawHost)) + if host == "" { + return false + } + + switch host { + case "localhost", "127.0.0.1", "::1", "host.docker.internal": + return true + } + + if ip := net.ParseIP(host); ip != nil { + return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() + } + + // Docker service names and other single-label local hostnames are + // permitted only for local HTTP development workflows. + return !strings.Contains(host, ".") +} + func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { if metadata == nil { return metadata, nil diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go index f467c49f..20e4248b 100644 --- a/backend/internal/service/worksmobile_client.go +++ b/backend/internal/service/worksmobile_client.go @@ -20,9 +20,11 @@ import ( "time" ) -const defaultWorksmobileAPIBaseURL = "https://www.worksapis.com" -const defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token" -const defaultWorksmobileOAuthScope = "directory" +const ( + defaultWorksmobileAPIBaseURL = "https://www.worksapis.com" + defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token" + defaultWorksmobileOAuthScope = "directory" +) type WorksmobileDirectoryClient interface { CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index 4f3fe8e6..06dde9ad 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -363,32 +363,41 @@ func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User) func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil } + func (f *fakeWorksmobileUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) { user := f.byID[id] return &user, nil } + func (f *fakeWorksmobileUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { return nil, nil } + func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { return nil, nil } + func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { return nil, 0, nil } + func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { return 0, nil } + func (f *fakeWorksmobileUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { return nil, nil } + func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { return nil, nil } + func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) { f.requestedTenantIDs = append([]string(nil), tenantIDs...) return f.byTenant, nil } + func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) { return nil, nil } @@ -396,12 +405,15 @@ func (f *fakeWorksmobileUserRepo) Delete(ctx context.Context, id string) error { func (f *fakeWorksmobileUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { return nil } + func (f *fakeWorksmobileUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) { return nil, nil } + func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) { return false, nil } + func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) { return "", nil } diff --git a/devfront/src/components/common/ForbiddenMessage.tsx b/devfront/src/components/common/ForbiddenMessage.tsx index 97c2af01..43dee424 100644 --- a/devfront/src/components/common/ForbiddenMessage.tsx +++ b/devfront/src/components/common/ForbiddenMessage.tsx @@ -14,34 +14,34 @@ export function ForbiddenMessage({ resourceToken }: Props) { let explanation = t( "msg.dev.forbidden.default", - "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.", + "You do not have permission to access this resource. Contact your administrator.", ); if (role === "rp_admin") { explanation = t( "msg.dev.forbidden.rp_admin", - "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.", + "RP administrators can only access resources for their assigned applications.", ); } else if (role === "tenant_admin") { explanation = t( "msg.dev.forbidden.tenant_admin", - "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.", + "Your tenant administrator permission is missing, misconfigured, or expired.", ); } else if (role === "user" || role === "tenant_member") { if (resourceToken === "consents") { explanation = t( "msg.dev.forbidden.user.consents", - "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Viewing consent records for this application requires an RP administrator, consent read, or consent revoke relationship. Request access from an administrator if needed.", ); } else if (resourceToken === "audit") { explanation = t( "msg.dev.forbidden.user.audit", - "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Viewing audit logs for this application requires an RP administrator or audit read relationship. Request access from an administrator if needed.", ); } else { explanation = t( "msg.dev.forbidden.user.clients", - "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target RP. Request access from an administrator if needed.", ); } } @@ -51,9 +51,9 @@ export function ForbiddenMessage({ resourceToken }: Props) { ? t("ui.dev.audit.title", "Audit Logs") : resourceToken === "consents" ? t("ui.dev.clients.consents.title", "User Consent Grants") - : t("ui.dev.clients.registry.subtitle", "연동 앱"); + : t("ui.dev.clients.registry.subtitle", "Connected Applications"); - const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", { + const title = t("msg.dev.forbidden.title", "Access denied: {{resource}}", { resource: resourceLabel, }); diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index be1da833..4ea438b9 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -32,7 +32,7 @@ const navItems = [ }, { labelKey: "ui.dev.nav.developer_request", - labelFallback: "개발자 권한 신청", + labelFallback: "Developer Access Request", to: "/developer-requests", icon: ClipboardCheck, }, @@ -71,7 +71,11 @@ function AppLayout() { }); const handleLogout = () => { - if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) { + if ( + window.confirm( + t("msg.dev.logout_confirm", "Are you sure you want to log out?"), + ) + ) { auth.removeUser(); navigate("/login"); } @@ -136,7 +140,7 @@ function AppLayout() { try { await auth.signinSilent(); } catch (error) { - console.error("세션 자동 연장에 실패했습니다.", error); + console.error("Silent session renewal failed.", error); } finally { isRenewInFlightRef.current = false; } @@ -184,7 +188,7 @@ function AppLayout() { try { await auth.signinSilent(); } catch (error) { - console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + console.error("Unlimited session keepalive renewal failed.", error); } finally { isRenewInFlightRef.current = false; } @@ -241,7 +245,7 @@ function AppLayout() { void auth .signinSilent() .catch((error) => { - console.error("세션 자동 연장에 실패했습니다.", error); + console.error("Silent session renewal failed.", error); }) .finally(() => { isRenewInFlightRef.current = false; @@ -289,15 +293,15 @@ function AppLayout() { let sessionToneClass = "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - let sessionText = t("ui.dev.session.active", "세션 활성"); + let sessionText = t("ui.dev.session.active", "Session active"); if (remainingMs === null) { sessionToneClass = "border-border bg-card text-muted-foreground"; - sessionText = t("ui.dev.session.unknown", "알 수 없음"); + sessionText = t("ui.dev.session.unknown", "Unknown"); } else if (remainingMs <= 0) { sessionToneClass = "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; - sessionText = t("ui.dev.session.expired", "세션 만료"); + sessionText = t("ui.dev.session.expired", "Session expired"); } else if ( remainingMinutes !== null && remainingSeconds !== null && @@ -307,7 +311,7 @@ function AppLayout() { "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; sessionText = t( "ui.dev.session.expiring", - "만료 임박: {{minutes}}분 {{seconds}}초 남음", + "Expiring soon: {{minutes}}m {{seconds}}s left", { minutes: remainingMinutes, seconds: remainingSeconds, @@ -316,7 +320,7 @@ function AppLayout() { } else { sessionText = t( "ui.dev.session.remaining", - "만료 예정: {{minutes}}분 {{seconds}}초 남음", + "Expires in {{minutes}}m {{seconds}}s", { minutes: remainingMinutes ?? 0, seconds: remainingSeconds ?? 0, @@ -343,7 +347,7 @@ function AppLayout() {

- {t("ui.dev.brand", "Baron 로그인")} + {t("ui.dev.brand", "Baron Sign In")}

{t("ui.dev.console_title", "Developer Console")} @@ -423,7 +427,7 @@ function AppLayout() { type="button" onClick={toggleTheme} className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20" - aria-label={t("ui.common.theme_toggle", "테마 전환")} + aria-label={t("ui.common.theme_toggle", "Toggle theme")} > {theme === "light" ? : } {theme === "light" @@ -447,7 +451,10 @@ function AppLayout() { className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20" aria-haspopup="menu" aria-expanded={isProfileMenuOpen} - aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")} + aria-label={t( + "ui.dev.profile.menu_aria", + "Open account menu", + )} >
{profileInitial} @@ -496,14 +503,14 @@ function AppLayout() {

- {t("ui.dev.session.auto_extend", "세션 만료 관리")} + {t("ui.dev.session.auto_extend", "Session expiry")}

{isSessionExpiryEnabled ? sessionText : t( "ui.dev.session.disabled", - "세션 만료 비활성화", + "Session expiry disabled", )}

@@ -539,7 +546,7 @@ function AppLayout() { }} > - {t("ui.dev.profile.title", "내 정보")} + {t("ui.dev.profile.title", "My Profile")}