From 38091429f4330ebc18483981bb8599ccef52eb38 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 16 Jun 2026 09:53:13 +0900 Subject: [PATCH 01/32] =?UTF-8?q?RP=20scope=20=EC=84=A4=EC=A0=95=EC=97=90?= =?UTF-8?q?=20offline=5Faccess=20=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clients/ClientGeneralPage.claims.test.tsx | 40 ++++++++++ .../features/clients/ClientGeneralPage.tsx | 73 +++++++++++++++++++ devfront/src/locales/en.toml | 7 ++ devfront/src/locales/ko.toml | 7 ++ devfront/src/locales/template.toml | 7 ++ 5 files changed, 134 insertions(+) diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index 726bfe06..b0d02820 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -450,6 +450,46 @@ describe("ClientGeneralPage RP claims", () => { expect(scopeInputs.some((input) => input.value === "old_claim")).toBe(true); }); + it("shows the offline_access guide in the scopes section and expands its details", async () => { + const { container } = await renderPage(); + + expect(container.textContent).toContain( + "Refresh token 사용 시 offline_access scope가 필요합니다.", + ); + expect(container.textContent).toContain( + "scope 목록에 offline_access를 포함하고", + ); + + const guideToggleButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => + (button.getAttribute("aria-label") ?? "").includes( + "offline_access 상세 안내 보기", + ), + ); + expect(guideToggleButton).toBeDefined(); + + await act(async () => { + guideToggleButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + await flush(); + + expect(container.textContent).toContain( + "Hydra 기준으로 refresh token 발급 조건", + ); + expect(container.textContent).toContain( + "authorization request scope에 offline 또는 offline_access 포함", + ); + expect(container.textContent).toContain( + "consent accept의 granted_scope에 offline 또는 offline_access 포함", + ); + expect(container.textContent).toContain( + "client grant_types에 refresh_token 포함", + ); + }); + it("blocks saving a number RP claim default value that is not numeric", async () => { const { container } = await renderPage(); diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index c68afeec..ca917138 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -639,6 +639,8 @@ function ClientGeneralPage() { const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false); const [isScopePickerOpen, setIsScopePickerOpen] = useState(false); + const [isOfflineAccessGuideOpen, setIsOfflineAccessGuideOpen] = + useState(false); const [scopes, setScopes] = useState(() => [ { id: "1", @@ -1970,6 +1972,77 @@ function ClientGeneralPage() { +
+
+
+
+ + + {t( + "ui.dev.clients.general.scopes.offline_access_title", + "Refresh token 사용 시 offline_access scope가 필요합니다.", + )} + +
+

+ {t( + "msg.dev.clients.general.scopes.offline_access_summary", + "RP가 refresh token을 사용하려면 scope 목록에 offline_access를 포함하고, consent와 grant type 설정도 함께 맞아야 합니다.", + )} +

+
+ +
+ {isOfflineAccessGuideOpen ? ( +
+

+ {t( + "msg.dev.clients.general.scopes.offline_access_conditions_title", + "Hydra 기준으로 refresh token 발급 조건", + )} +

+
    +
  • + {t( + "msg.dev.clients.general.scopes.offline_access_condition_request", + "authorization request scope에 offline 또는 offline_access 포함", + )} +
  • +
  • + {t( + "msg.dev.clients.general.scopes.offline_access_condition_consent", + "consent accept의 granted_scope에 offline 또는 offline_access 포함", + )} +
  • +
  • + {t( + "msg.dev.clients.general.scopes.offline_access_condition_grant_type", + "client grant_types에 refresh_token 포함", + )} +
  • +
+
+ ) : null} +
+ {isScopePickerOpen && (
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 4f2b0c60..8636a13d 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -452,6 +452,11 @@ session_required_off = "Off: process logout using sub even if sid is missing." empty = "No scopes registered." subtitle = "Define the permission scopes this application can request." tenant = "Tenant access claim" +offline_access_summary = "If the RP needs refresh tokens, include offline_access in the scope list and align the consent and grant type settings as well." +offline_access_conditions_title = "Hydra conditions for issuing refresh tokens" +offline_access_condition_request = "Include offline or offline_access in the authorization request scope." +offline_access_condition_consent = "Include offline or offline_access in the consent accept granted_scope." +offline_access_condition_grant_type = "Include refresh_token in the client grant_types." [msg.dev.clients.general.id_token_claims] subtitle = "Manage RP-specific extension claims separately." @@ -1590,6 +1595,8 @@ add = "Scope Add" description_placeholder = "Description Placeholder" name_placeholder = "e.g. profile" title = "Scopes" +offline_access_title = "offline_access scope is required when using refresh tokens." +offline_access_toggle = "Show offline_access help" [ui.dev.clients.general.scopes.table] description = "Scope Description" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index bc5638ca..0b65dd9f 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -452,6 +452,11 @@ session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처 empty = "등록된 스코프가 없습니다." subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다." tenant = "소속 테넌트 정보 접근" +offline_access_summary = "RP가 refresh token을 사용하려면 scope 목록에 offline_access를 포함하고, consent와 grant type 설정도 함께 맞아야 합니다." +offline_access_conditions_title = "Hydra 기준으로 refresh token 발급 조건" +offline_access_condition_request = "authorization request scope에 offline 또는 offline_access 포함" +offline_access_condition_consent = "consent accept의 granted_scope에 offline 또는 offline_access 포함" +offline_access_condition_grant_type = "client grant_types에 refresh_token 포함" [msg.dev.clients.general.id_token_claims] subtitle = "RP 전용 확장 claim을 구분해서 관리합니다." @@ -1589,6 +1594,8 @@ add = "스코프 추가" description_placeholder = "권한에 대한 설명" name_placeholder = "e.g. profile" title = "스코프" +offline_access_title = "Refresh token 사용 시 offline_access scope가 필요합니다." +offline_access_toggle = "offline_access 상세 안내 보기" [ui.dev.clients.general.scopes.table] description = "설명" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index bb8af261..1a84e467 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -499,6 +499,11 @@ session_required_off = "" empty = "" subtitle = "" tenant = "" +offline_access_summary = "" +offline_access_conditions_title = "" +offline_access_condition_request = "" +offline_access_condition_consent = "" +offline_access_condition_grant_type = "" [msg.dev.clients.general.security] private_help = "" @@ -1638,6 +1643,8 @@ add = "" description_placeholder = "" name_placeholder = "" title = "" +offline_access_title = "" +offline_access_toggle = "" [ui.dev.clients.general.scopes.table] description = "" From c6625521578df585bf0b4da3d04b505cf8538ef7 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 16 Jun 2026 10:31:40 +0900 Subject: [PATCH 02/32] =?UTF-8?q?scopes=20=EC=95=88=EB=82=B4=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=EC=9D=98=20offline=5Faccess=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=EC=84=B1=20=EC=9D=B4=EB=A6=84=20=EC=B6=A9=EB=8F=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clients/ClientGeneralPage.claims.test.tsx | 4 +-- .../features/clients/ClientGeneralPage.tsx | 2 +- devfront/src/locales/en.toml | 2 +- devfront/src/locales/ko.toml | 2 +- userfront/pubspec.lock | 32 ++++++++++++------- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx index b0d02820..2fa975a5 100644 --- a/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx @@ -463,9 +463,7 @@ describe("ClientGeneralPage RP claims", () => { const guideToggleButton = Array.from( container.querySelectorAll("button"), ).find((button) => - (button.getAttribute("aria-label") ?? "").includes( - "offline_access 상세 안내 보기", - ), + (button.getAttribute("aria-label") ?? "").includes("상세 안내 보기"), ); expect(guideToggleButton).toBeDefined(); diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index ca917138..cbd1d153 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -2000,7 +2000,7 @@ function ClientGeneralPage() { aria-expanded={isOfflineAccessGuideOpen} aria-label={t( "ui.dev.clients.general.scopes.offline_access_toggle", - "offline_access 상세 안내 보기", + "상세 안내 보기", )} > {isOfflineAccessGuideOpen ? ( diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 8636a13d..d9df8136 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -1596,7 +1596,7 @@ description_placeholder = "Description Placeholder" name_placeholder = "e.g. profile" title = "Scopes" offline_access_title = "offline_access scope is required when using refresh tokens." -offline_access_toggle = "Show offline_access help" +offline_access_toggle = "Show details" [ui.dev.clients.general.scopes.table] description = "Scope Description" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 0b65dd9f..bad71c01 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -1595,7 +1595,7 @@ description_placeholder = "권한에 대한 설명" name_placeholder = "e.g. profile" title = "스코프" offline_access_title = "Refresh token 사용 시 offline_access scope가 필요합니다." -offline_access_toggle = "offline_access 상세 안내 보기" +offline_access_toggle = "상세 안내 보기" [ui.dev.clients.general.scopes.table] description = "설명" diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index b23d80a9..8b6fff8c 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" cli_config: dependency: transitive description: @@ -268,6 +268,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" leak_tracker: dependency: transitive description: @@ -320,18 +328,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -653,26 +661,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" toml: dependency: "direct main" description: From 2d1ae96e3ee898d5da3c745c009b013498d525d8 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 10:31:53 +0900 Subject: [PATCH 03/32] =?UTF-8?q?promtail,=20docker:=20Ory/Kratos=20?= =?UTF-8?q?=EB=93=B1=20baron=5F=20=EC=A0=91=EB=91=90=EC=82=AC=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=EC=9D=98=20job?= =?UTF-8?q?/service=20=EB=9D=BC=EB=B2=A8=20=EB=88=84=EB=9D=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20Loki=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(#1155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/promtail-config.template.yaml | 6 +++--- docs/external_healthcheck_monitoring_design.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docker/promtail-config.template.yaml b/docker/promtail-config.template.yaml index 45ac4bfb..d1802254 100644 --- a/docker/promtail-config.template.yaml +++ b/docker/promtail-config.template.yaml @@ -32,10 +32,10 @@ scrape_configs: regex: '/(.*)' target_label: 'container_name' - # 4. 서비스 상세 라벨 부여 (baron_ 접두사 제거 등) + # 4. 서비스 상세 라벨 부여 (baron_ 접두사 제거 등, 접두사가 없는 경우 그대로 유지) - source_labels: ['container_name'] - regex: 'baron_(.*)' + regex: '(?:baron_)?(.*)' target_label: 'service' - source_labels: ['container_name'] - regex: 'baron_(.*)' + regex: '(?:baron_)?(.*)' target_label: 'job' diff --git a/docs/external_healthcheck_monitoring_design.md b/docs/external_healthcheck_monitoring_design.md index c1b644be..f66d65c9 100644 --- a/docs/external_healthcheck_monitoring_design.md +++ b/docs/external_healthcheck_monitoring_design.md @@ -140,12 +140,12 @@ scrape_configs: - source_labels: ['container_name'] regex: '(baron_.*|oathkeeper|kratos|hydra|keto)' action: keep - # 컨테이너 명에서 앞의 접두사를 떼어 서비스 및 잡 라벨 부여 (예: baron_backend -> backend) + # 컨테이너 명에서 앞의 접두사를 떼어 서비스 및 잡 라벨 부여 (예: baron_backend -> backend, kratos -> kratos) - source_labels: ['container_name'] - regex: 'baron_(.*)' + regex: '(?:baron_)?(.*)' target_label: 'service' - source_labels: ['container_name'] - regex: 'baron_(.*)' + regex: '(?:baron_)?(.*)' target_label: 'job' # 동적 라벨 추가 - target_label: 'app_env' From 66556c9f03469b066673311113cffcc0159769e3 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 16 Jun 2026 13:54:48 +0900 Subject: [PATCH 04/32] =?UTF-8?q?devfront=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=A1=9C=EC=BC=80=EC=9D=BC=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientGeneralPage.tsx | 32 +++++------ .../clients/components/TenantAccessPicker.tsx | 22 ++++---- .../developerAccessPages.test.ts | 33 +++++++++++- .../developer-access/developerAccessPages.ts | 54 ++++++++++++------- .../developer-grants/DeveloperGrantsPage.tsx | 8 +-- .../DeveloperRequestPage.tsx | 8 +-- devfront/src/lib/i18n.test.ts | 29 ++++++++++ devfront/src/locales/en.toml | 43 ++++++++++++--- devfront/src/locales/ko.toml | 41 ++++++++++++-- devfront/src/locales/template.toml | 42 +++++++++++++-- 10 files changed, 241 insertions(+), 71 deletions(-) create mode 100644 devfront/src/lib/i18n.test.ts diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index cbd1d153..6d0c0b51 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -1178,14 +1178,14 @@ function ClientGeneralPage() { if (!trimmedJwksUri) { validationErrors.push( t( - "msg.dev.clients.general.public_key.validation.missing_jwks_uri", + "ui.dev.clients.general.public_key.validation.missing_jwks_uri", "JWKS URI를 입력해야 합니다.", ), ); } else if (!isValidUrl(trimmedJwksUri)) { validationErrors.push( t( - "msg.dev.clients.general.public_key.validation.invalid_jwks_uri", + "ui.dev.clients.general.public_key.validation.invalid_jwks_uri", "JWKS URI 형식이 올바르지 않습니다.", ), ); @@ -1193,7 +1193,7 @@ function ClientGeneralPage() { if (unsupportedParsedAlgorithms.length > 0) { validationErrors.push( t( - "msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", + "ui.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", "JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}", { details: unsupportedParsedAlgorithmSummary }, ), @@ -1202,7 +1202,7 @@ function ClientGeneralPage() { if (missingParsedAlgorithms.length > 0) { validationErrors.push( t( - "msg.dev.clients.general.public_key.validation.missing_parsed_algorithms", + "ui.dev.clients.general.public_key.validation.missing_parsed_algorithms", "JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}", { details: missingParsedAlgorithmSummary }, ), @@ -2050,13 +2050,13 @@ function ClientGeneralPage() {

{t( "ui.dev.clients.general.scopes.picker_title", - "추가할 scope 선택", + "Add a scope", )}

{t( - "msg.dev.clients.general.scopes.picker_help", - "지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다.", + "ui.dev.clients.general.scopes.picker_help", + "Choose a supported scope or custom claim key to add it to the scope list.", )}

@@ -2427,13 +2427,13 @@ function ClientGeneralPage() { {tenantAccessRestricted ? (
- +

{t( - "msg.dev.clients.general.security.headless_login_enable_help", - "Baron SSO 로그인 창 대신 RP 자체 로그인 UI를 사용하고, RP backend의 서명 키로 클라이언트를 검증하려는 경우 활성화합니다.", + "ui.dev.clients.general.security.headless_login_enable_help", + "Enable this when the RP uses its own login UI instead of the Baron SSO login page and the RP backend validates the client with a signing key.", )}

diff --git a/devfront/src/features/clients/components/TenantAccessPicker.tsx b/devfront/src/features/clients/components/TenantAccessPicker.tsx index 259bc938..c5d90e8f 100644 --- a/devfront/src/features/clients/components/TenantAccessPicker.tsx +++ b/devfront/src/features/clients/components/TenantAccessPicker.tsx @@ -57,7 +57,7 @@ export function TenantAccessPicker({ aria-modal="true" aria-label={t( "ui.dev.clients.general.tenant_access.picker_title", - "테넌트 선택", + "Select tenant", )} >
@@ -66,13 +66,13 @@ export function TenantAccessPicker({

{t( "ui.dev.clients.general.tenant_access.picker_title", - "테넌트 선택", + "Select tenant", )}

{t( - "msg.dev.clients.general.tenant_access.picker_description", - "orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다.", + "ui.dev.clients.general.tenant_access.picker_description", + "Choose the tenants to allow from the orgfront org chart and add them to the list.", )}

@@ -83,7 +83,7 @@ export function TenantAccessPicker({ className="shrink-0" onClick={() => setPickerOpen(false)} > - {t("ui.common.close", "닫기")} + {t("ui.common.close", "Close")}
@@ -102,7 +102,7 @@ export function TenantAccessPicker({ variant="outline" onClick={() => setPickerOpen(false)} > - {t("ui.common.close", "닫기")} + {t("ui.common.close", "Close")}
@@ -123,7 +123,7 @@ export function TenantAccessPicker({ {t( "ui.dev.clients.general.tenant_access.open_picker", - "테넌트 선택기 열기", + "Open tenant picker", )} @@ -132,13 +132,13 @@ export function TenantAccessPicker({
{selectedCount > 0 ? t( - "msg.dev.clients.general.tenant_access.picker_hint_with_count", - "현재 {{count}}개가 선택되어 있습니다.", + "ui.dev.clients.general.tenant_access.picker_hint_with_count", + "{{count}} tenants selected.", { count: selectedCount }, ) : t( - "msg.dev.clients.general.tenant_access.picker_hint", - "선택기를 열어 허용 테넌트를 추가하세요.", + "ui.dev.clients.general.tenant_access.picker_hint", + "Open the picker to add allowed tenants.", )}
diff --git a/devfront/src/features/developer-access/developerAccessPages.test.ts b/devfront/src/features/developer-access/developerAccessPages.test.ts index 2415cf42..0c6528fb 100644 --- a/devfront/src/features/developer-access/developerAccessPages.test.ts +++ b/devfront/src/features/developer-access/developerAccessPages.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, it } from "vitest"; -import { normalizeDeveloperAccessPageSelection } from "./developerAccessPages"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + developerAccessPagesToLabel, + getDeveloperAccessPageLabel, + normalizeDeveloperAccessPageSelection, +} from "./developerAccessPages"; + +beforeEach(() => { + window.localStorage.clear(); + window.localStorage.setItem("locale", "ko"); +}); + +afterEach(() => { + window.localStorage.clear(); +}); describe("developer access pages", () => { it("collapses all non-all pages into all", () => { @@ -21,4 +34,20 @@ describe("developer access pages", () => { it("keeps explicit all selection", () => { expect(normalizeDeveloperAccessPageSelection(["all"])).toEqual(["all"]); }); + + it("returns localized labels for access pages", () => { + expect(getDeveloperAccessPageLabel("all")).toBe("전체"); + expect(developerAccessPagesToLabel(["overview", "audit"])).toBe( + "개요, 감사로그", + ); + + window.localStorage.setItem("locale", "en"); + + expect(getDeveloperAccessPageLabel("client_create")).toBe( + "Add linked app", + ); + expect(developerAccessPagesToLabel(["overview", "audit"])).toBe( + "Overview, Audit Logs", + ); + }); }); diff --git a/devfront/src/features/developer-access/developerAccessPages.ts b/devfront/src/features/developer-access/developerAccessPages.ts index ce7608f1..0e44187f 100644 --- a/devfront/src/features/developer-access/developerAccessPages.ts +++ b/devfront/src/features/developer-access/developerAccessPages.ts @@ -1,3 +1,5 @@ +import { t } from "../../lib/i18n"; + export type DeveloperAccessPage = | "all" | "overview" @@ -10,15 +12,40 @@ export const developerAccessPageOrder: DeveloperAccessPage[] = [ "audit", ]; -export const developerAccessPageOptions: Array<{ +export function getDeveloperAccessPageLabel(page: DeveloperAccessPage): string { + switch (page) { + case "all": + return t("ui.dev.access_pages.all", "전체"); + case "overview": + return t("ui.dev.access_pages.overview", "개요"); + case "client_create": + return t("ui.dev.access_pages.client_create", "연동 앱 추가"); + case "audit": + return t("ui.dev.access_pages.audit", "감사로그"); + default: + return page; + } +} + +export function getDeveloperAccessPageOptions(): Array<{ value: DeveloperAccessPage; label: string; -}> = [ - { value: "all", label: "전체" }, - { value: "overview", label: "개요" }, - { value: "client_create", label: "연동 앱 추가" }, - { value: "audit", label: "감사로그" }, -]; +}> { + return developerAccessPageOrder.length > 0 + ? [ + { value: "all", label: getDeveloperAccessPageLabel("all") }, + { + value: "overview", + label: getDeveloperAccessPageLabel("overview"), + }, + { + value: "client_create", + label: getDeveloperAccessPageLabel("client_create"), + }, + { value: "audit", label: getDeveloperAccessPageLabel("audit") }, + ] + : []; +} export function normalizeDeveloperAccessPages( pages: Array, @@ -61,20 +88,11 @@ export function normalizeDeveloperAccessPageSelection( export function developerAccessPagesToLabel(pages?: Array) { const normalized = normalizeDeveloperAccessPages(pages ?? []); if (normalized.length === 0 || normalized.includes("all")) { - return "전체"; + return getDeveloperAccessPageLabel("all"); } return normalized .map((page) => { - switch (page) { - case "overview": - return "개요"; - case "client_create": - return "연동 앱 추가"; - case "audit": - return "감사로그"; - default: - return page; - } + return getDeveloperAccessPageLabel(page); }) .join(", "); } diff --git a/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx index 8b967b86..79249fd8 100644 --- a/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx +++ b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx @@ -38,7 +38,8 @@ import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; import { type DeveloperAccessPage, - developerAccessPageOptions, + developerAccessPagesToLabel, + getDeveloperAccessPageOptions, normalizeDeveloperAccessPageSelection, normalizeDeveloperAccessPages, } from "../developer-access/developerAccessPages"; @@ -62,6 +63,7 @@ export default function DeveloperGrantsPage() { }); const profileRole = me?.role?.trim() || role; const isSuperAdmin = profileRole === "super_admin"; + const developerAccessPageOptions = getDeveloperAccessPageOptions(); const [userSearch, setUserSearch] = useState(""); const deferredUserSearch = useDeferredValue(userSearch.trim()); @@ -621,9 +623,7 @@ export default function DeveloperGrantsPage() { : ["all"] ).map((page) => ( - {developerAccessPageOptions.find( - (option) => option.value === page, - )?.label ?? page} + {developerAccessPagesToLabel([page])} ))} diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.tsx index 5f11a631..fee23d91 100644 --- a/devfront/src/features/developer-request/DeveloperRequestPage.tsx +++ b/devfront/src/features/developer-request/DeveloperRequestPage.tsx @@ -49,7 +49,8 @@ import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; import { type DeveloperAccessPage, - developerAccessPageOptions, + developerAccessPagesToLabel, + getDeveloperAccessPageOptions, normalizeDeveloperAccessPageSelection, normalizeDeveloperAccessPages, } from "../developer-access/developerAccessPages"; @@ -287,9 +288,7 @@ export default function DeveloperRequestPage() { req.accessPages, ).map((page) => ( - {developerAccessPageOptions.find( - (option) => option.value === page, - )?.label ?? page} + {developerAccessPagesToLabel([page])} )) ) : ( @@ -479,6 +478,7 @@ function RequestAccessModal({ const [accessPages, setAccessPages] = useState([ "all", ]); + const developerAccessPageOptions = getDeveloperAccessPageOptions(); const organizationDisplay = organization.trim() || t("ui.common.na", "없음"); useEffect(() => { diff --git a/devfront/src/lib/i18n.test.ts b/devfront/src/lib/i18n.test.ts new file mode 100644 index 00000000..a16adc1b --- /dev/null +++ b/devfront/src/lib/i18n.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { t } from "./i18n"; + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("i18n", () => { + it("returns English copy for the developer request and grants screens", () => { + window.localStorage.setItem("locale", "en"); + + expect(t("ui.dev.request.list.title", "신청 내역")).toBe("Request History"); + expect(t("msg.dev.request.list.approved_count", "총 {{count}}명의 사용자가 승인되었습니다.", { count: 0 })).toBe( + "0 users have been approved.", + ); + expect(t("ui.dev.grants.form.title", "직접 부여")).toBe("Direct Grant"); + expect( + t( + "msg.dev.grants.form.description", + "사용자를 선택하면 현재 소속 정보가 표시되고, 그 사용자에게 개발자 권한을 즉시 부여합니다.", + ), + ).toBe( + "Select a user to view their current tenant, email, and phone, then grant developer access immediately.", + ); + expect( + t("msg.dev.grants.list.description", "현재 부여된 개발자 권한 목록입니다."), + ).toBe("Current developer access grants."); + }); +}); diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index d9df8136..60ce8c2c 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -321,12 +321,14 @@ admin_desc = "Manage developer access requests submitted by users." approved = "Approved." cancelled = "Approval has been cancelled." empty = "No requests found." -list.approved_count = "{{count}} users have been approved." need_cancel_notes = "Please enter a reason for cancelling approval." need_notes = "Please enter a rejection reason." rejected = "Rejected." user_desc = "Review your request history and submit a new access request." +[msg.dev.request.list] +approved_count = "{{count}} users have been approved." + [msg.dev.request.modal] desc = "Please enter the reason for your request. It will be approved after administrator review." tenant_required = "Please submit a developer access request." @@ -573,10 +575,8 @@ admin_notes_placeholder = "e.g. Grant access after verifying the test environmen empty = "There are no granted permissions." forbidden = "Only super admin can directly grant developer access." forbidden_desc = "This screen is available only to super admin." -form.description = "Select a user to view their current tenant, email, and phone, then grant developer access immediately." selected_info_description = "Review the selected user's tenant, email, and phone." user_section_description = "Enter a search term to select a user. The next-step information stays empty until a user is chosen." -list.description = "Current developer access grants." load_error = "Failed to load developer access grants." reason = "Grant reason" revoke = "Revoke" @@ -588,6 +588,13 @@ tenant_required = "The selected user's tenant information is unavailable." tenant_missing = "No tenant information is available for the selected user." user_required = "Select a user before granting access." phone_missing = "No phone number is registered." +pages_hint = "If you select All, Overview, Add linked app, and Audit Logs are all included." + +[msg.dev.grants.form] +description = "Select a user to view their current tenant, email, and phone, then grant developer access immediately." + +[msg.dev.grants.list] +description = "Current developer access grants." [msg.dev.dashboard.notice] consent_audit = "Consent Audit" @@ -1351,10 +1358,8 @@ admin_notes = "Grant Reason" all_tenants = "All Tenants" approved = "Approved" date = "Granted At" -form.title = "Direct Grant" grant = "Grant Directly" input_section = "Input" -list.title = "Granted Access" pages = "Access Pages" read_only = "Read Only" reason = "Grant Reason" @@ -1369,6 +1374,12 @@ user_section = "User Selection" user = "User" user_search_placeholder = "Search by name or email..." +[ui.dev.grants.form] +title = "Direct Grant" + +[ui.dev.grants.list] +title = "Granted Access" + [ui.dev.request.modal] email = "Email" name = "Name" @@ -1380,6 +1391,12 @@ reason_placeholder = "e.g. I need to create an OIDC client for internal service role = "Role" title = "Developer Access Request" +[ui.dev.access_pages] +all = "All" +overview = "Overview" +client_create = "Add linked app" +audit = "Audit Logs" + [ui.dev.request.status] approved = "Approved" cancelled = "Approval Cancelled" @@ -1597,6 +1614,8 @@ name_placeholder = "e.g. profile" title = "Scopes" offline_access_title = "offline_access scope is required when using refresh tokens." offline_access_toggle = "Show details" +picker_title = "Select a scope to add" +picker_help = "Choose a supported scope or custom claim key to add it to the scope list." [ui.dev.clients.general.scopes.table] description = "Scope Description" @@ -1617,6 +1636,12 @@ empty = "No tenants match your search." hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant." autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list." validation_required = "Select at least one allowed tenant when tenant access restriction is enabled." +picker_title = "Select tenant" +picker_label = "Add allowed tenant" +open_picker = "Open tenant picker" +picker_description = "Choose the tenants to allow from the orgfront org chart and add them to the list." +picker_hint = "Open the picker to add allowed tenants." +picker_hint_with_count = "{{count}} tenants selected." [ui.dev.clients.general.id_token_claims] title = "Custom Claims" @@ -1652,7 +1677,7 @@ pkce = "PKCE" headless_login = "Headless Login" title = "Security Settings" headless_login_enable = "Headless Login (Custom Login UI)" -headless_login_enable_help = "Enable this when the RP uses its own login UI and the RP backend proves the client with signed keys instead of the Baron SSO login page." +headless_login_enable_help = "Enable this when the RP uses its own login UI instead of the Baron SSO login page and the RP backend validates the client with a signing key." [ui.dev.clients.general.public_key] auth_method = "Token Endpoint Auth Method" @@ -1683,6 +1708,12 @@ cache_status = "Status" cache_uri = "JWKS URI" revoke_cache = "Revoke Cache" +[ui.dev.clients.general.public_key.validation] +missing_jwks_uri = "Enter a JWKS URI." +invalid_jwks_uri = "JWKS URI format is invalid." +unsupported_parsed_algorithms = "The JWKS contains unsupported algorithms: {{details}}" +missing_parsed_algorithms = "The JWKS contains keys without an `alg` declaration: {{details}}" + [ui.dev.clients.relationships] title = "Client Relationships" add_title = "Add Relationship" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index bad71c01..d175aa63 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -321,12 +321,14 @@ admin_desc = "사용자들의 개발자 권한 신청 내역을 관리합니다. approved = "승인되었습니다." cancelled = "승인이 취소되었습니다." empty = "신청 내역이 없습니다." -list.approved_count = "총 {{count}}명의 사용자가 승인되었습니다." need_cancel_notes = "승인 취소 사유를 입력해주세요." need_notes = "반려 사유를 입력해주세요." rejected = "반려되었습니다." user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다." +[msg.dev.request.list] +approved_count = "총 {{count}}명의 사용자가 승인되었습니다." + [msg.dev.request.modal] desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다." tenant_required = "개발자 권한 신청을 진행해 주세요." @@ -573,10 +575,8 @@ admin_notes_placeholder = "예: 테스트 환경 확인 후 권한 부여" empty = "부여된 권한이 없습니다." forbidden = "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다." forbidden_desc = "이 화면은 super admin만 사용할 수 있습니다." -form.description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다." selected_info_description = "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다." user_section_description = "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다." -list.description = "현재 부여된 개발자 권한 목록입니다." load_error = "개발자 권한 목록을 불러오지 못했습니다." reason = "부여 사유" revoke = "회수" @@ -588,6 +588,13 @@ tenant_required = "선택한 사용자의 테넌트 정보를 확인할 수 없 tenant_missing = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다." user_required = "부여할 사용자를 선택해주세요." phone_missing = "등록된 전화번호가 없습니다." +pages_hint = "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다." + +[msg.dev.grants.form] +description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다." + +[msg.dev.grants.list] +description = "현재 부여된 개발자 권한 목록입니다." [msg.dev.dashboard.notice] consent_audit = "Consent 회수는 감사 로그와 연계" @@ -1351,10 +1358,8 @@ admin_notes = "부여 사유" all_tenants = "전체 테넌트" approved = "승인됨" date = "부여일" -form.title = "직접 부여" grant = "직접 부여" input_section = "입력" -list.title = "부여된 권한" pages = "권한 페이지" read_only = "읽기 전용" reason = "부여 사유" @@ -1369,6 +1374,12 @@ user_section = "사용자 선택" user = "사용자" user_search_placeholder = "이름 또는 이메일 검색..." +[ui.dev.grants.form] +title = "직접 부여" + +[ui.dev.grants.list] +title = "부여된 권한" + [ui.dev.request.modal] email = "이메일" name = "성함" @@ -1380,6 +1391,12 @@ reason_placeholder = "예: 자체 서비스 연동 및 테스트용 OIDC 클라 role = "역할" title = "개발자 등록 신청" +[ui.dev.access_pages] +all = "전체" +overview = "개요" +client_create = "연동 앱 추가" +audit = "감사로그" + [ui.dev.request.status] approved = "승인됨" cancelled = "승인 취소됨" @@ -1596,6 +1613,8 @@ name_placeholder = "e.g. profile" title = "스코프" offline_access_title = "Refresh token 사용 시 offline_access scope가 필요합니다." offline_access_toggle = "상세 안내 보기" +picker_title = "추가할 scope 선택" +picker_help = "지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다." [ui.dev.clients.general.scopes.table] description = "설명" @@ -1616,6 +1635,12 @@ empty = "검색 결과가 없습니다." hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다." autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다." validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다." +picker_title = "테넌트 선택" +picker_label = "허용 테넌트 추가" +open_picker = "테넌트 선택기 열기" +picker_description = "orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다." +picker_hint = "선택기를 열어 허용 테넌트를 추가하세요." +picker_hint_with_count = "현재 {{count}}개가 선택되어 있습니다." [ui.dev.clients.general.id_token_claims] title = "커스텀 클레임" @@ -1682,6 +1707,12 @@ cache_status = "상태" cache_uri = "JWKS URI" revoke_cache = "캐시 삭제" +[ui.dev.clients.general.public_key.validation] +missing_jwks_uri = "JWKS URI를 입력해야 합니다." +invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다." +unsupported_parsed_algorithms = "JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}" +missing_parsed_algorithms = "JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}" + [ui.dev.clients.relationships] title = "클라이언트 관계" add_title = "관계 추가" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 1a84e467..45f2d56f 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -335,16 +335,19 @@ admin_desc = "" approved = "" cancelled = "" empty = "" -list.approved_count = "" need_cancel_notes = "" need_notes = "" rejected = "" user_desc = "" +[msg.dev.request.list] +approved_count = "" + [msg.dev.request.modal] desc = "" tenant_required = "" tenant_required_detail = "" +pages_hint = "" [msg.dev.request.status] approved = "" @@ -610,14 +613,13 @@ admin_notes_placeholder = "" empty = "" forbidden = "" forbidden_desc = "" -form.description = "" selected_info_description = "" user_section_description = "" -list.description = "" load_error = "" reason = "" revoke = "" revoke_success = "" +pages_hint = "" search_empty = "" search_loading = "" selected_user = "" @@ -627,6 +629,12 @@ user_required = "" phone_missing = "" required = "" +[msg.dev.grants.form] +description = "" + +[msg.dev.grants.list] +description = "" + [msg.dev.dashboard.notice] consent_audit = "" dev_scope = "" @@ -1403,10 +1411,8 @@ admin_notes = "" all_tenants = "" approved = "" date = "" -form.title = "" grant = "" input_section = "" -list.title = "" read_only = "" reason = "" reason_placeholder = "" @@ -1420,6 +1426,12 @@ user_section = "" user = "" user_search_placeholder = "" +[ui.dev.grants.form] +title = "" + +[ui.dev.grants.list] +title = "" + [ui.dev.request.modal] email = "" name = "" @@ -1430,6 +1442,12 @@ reason_placeholder = "" role = "" title = "" +[ui.dev.access_pages] +all = "" +overview = "" +client_create = "" +audit = "" + [ui.dev.request.status] approved = "" cancelled = "" @@ -1645,6 +1663,8 @@ name_placeholder = "" title = "" offline_access_title = "" offline_access_toggle = "" +picker_title = "" +picker_help = "" [ui.dev.clients.general.scopes.table] description = "" @@ -1665,6 +1685,12 @@ empty = "" hint = "" autocomplete_hint = "" validation_required = "" +picker_title = "" +picker_label = "" +open_picker = "" +picker_description = "" +picker_hint = "" +picker_hint_with_count = "" [ui.dev.clients.general.id_token_claims] title = "" @@ -1730,6 +1756,12 @@ cache_status = "" cache_uri = "" revoke_cache = "" +[ui.dev.clients.general.public_key.validation] +missing_jwks_uri = "" +invalid_jwks_uri = "" +unsupported_parsed_algorithms = "" +missing_parsed_algorithms = "" + [ui.dev.clients.relationships] title = "" add_title = "" From 92ba779ff9230ff3d3bb82864ad1998c12013344 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 16 Jun 2026 15:07:46 +0900 Subject: [PATCH 05/32] =?UTF-8?q?=EA=B0=9C=EC=9A=94=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=81=B4=EB=A0=88=EC=9E=84=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=ED=91=9C=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overview/recentClientChanges.test.ts | 145 +++++++++++++ .../features/overview/recentClientChanges.ts | 203 +++++++++++++++++- 2 files changed, 346 insertions(+), 2 deletions(-) diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts index b69c9a28..243bfb08 100644 --- a/devfront/src/features/overview/recentClientChanges.test.ts +++ b/devfront/src/features/overview/recentClientChanges.test.ts @@ -91,6 +91,117 @@ describe("recent client changes", () => { ]); }); + it("ignores audit object key order changes in update details", () => { + mockLocale("ko"); + + expect( + buildRecentClientChangeDetails("UPDATE_CLIENT", { + before: { + id_token_claims: [ + { + key: "license", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "text", + writePermission: "admin_only", + }, + { + key: "date", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "date", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "date", + value: "", + valueType: "date", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }), + ).toEqual([]); + }); + + it("summarizes id_token_claims additions and removals", () => { + mockLocale("ko"); + + expect( + buildRecentClientChangeDetails("UPDATE_CLIENT", { + before: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "date", + value: "", + valueType: "date", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "test", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }), + ).toEqual([ + { + label: "커스텀 클레임", + value: "+ test (text), - date (date)", + }, + ]); + }); + it("builds recent client changes with sorting, filtering, and detail slicing", () => { mockLocale("ko"); @@ -192,6 +303,40 @@ describe("recent client changes", () => { after: { name: "Ignored" }, }, ), + makeAuditLog( + "evt-9", + "2026-05-27T15:00:00.000Z", + "UPDATE_CLIENT", + "client-a", + { + before: { + id_token_claims: [ + { + key: "license", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "text", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }, + ), ]; const changes = buildRecentClientChanges(auditLogs, clients); diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index 3e6614a0..b02c34be 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -33,6 +33,27 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function normalizeAuditValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => normalizeAuditValue(item)); + } + + if (isRecord(value)) { + return Object.keys(value) + .sort() + .reduce>((acc, key) => { + acc[key] = normalizeAuditValue(value[key]); + return acc; + }, {}); + } + + return value; +} + +function auditValueSignature(value: unknown) { + return JSON.stringify(normalizeAuditValue(value)); +} + export function getRecentClientActionLabel(action: string) { switch (action) { case "CREATE_CLIENT": @@ -74,11 +95,169 @@ function getRecentClientFieldLabel(key: string) { "ui.dev.clients.details.credentials.client_secret", "클라이언트 시크릿", ); + case "id_token_claims": + return t( + "ui.dev.clients.general.id_token_claims.title", + "Custom Claims", + ); default: return key; } } +function getIdTokenClaimIdentity(claim: Record) { + const namespace = + typeof claim.namespace === "string" && claim.namespace + ? claim.namespace + : null; + const key = typeof claim.key === "string" && claim.key ? claim.key : null; + + if (!namespace || !key) { + return null; + } + + return { namespace, key }; +} + +function formatIdTokenClaimDisplayName(claim: Record) { + const identity = getIdTokenClaimIdentity(claim); + if (!identity) { + return "unknown"; + } + if (identity.namespace === "rp_claims") { + return identity.key; + } + return `${identity.namespace}:${identity.key}`; +} + +function isSimpleAuditScalar(value: unknown) { + return ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +function formatIdTokenClaimChangeSummary( + beforeValue: unknown, + afterValue: unknown, +) { + if (!isRecord(beforeValue) || !isRecord(afterValue)) { + return null; + } + + const beforeDisplayName = formatIdTokenClaimDisplayName(beforeValue); + const afterDisplayName = formatIdTokenClaimDisplayName(afterValue); + if (beforeDisplayName !== afterDisplayName) { + return `~ ${beforeDisplayName} → ${afterDisplayName}`; + } + + const beforeValueType = + typeof beforeValue.valueType === "string" ? beforeValue.valueType : null; + const afterValueType = + typeof afterValue.valueType === "string" ? afterValue.valueType : null; + + if ( + beforeValueType && + afterValueType && + beforeValueType !== afterValueType + ) { + return `~ ${beforeDisplayName}: ${beforeValueType} → ${afterValueType}`; + } + + const beforeScalar = beforeValue.value; + const afterScalar = afterValue.value; + if ( + isSimpleAuditScalar(beforeScalar) && + isSimpleAuditScalar(afterScalar) && + formatAuditValue(beforeScalar) !== formatAuditValue(afterScalar) + ) { + return `~ ${beforeDisplayName}: ${formatAuditValue(beforeScalar)} → ${formatAuditValue(afterScalar)}`; + } + + return `~ ${beforeDisplayName}`; +} + +function summarizeIdTokenClaimArrayChange( + beforeValue: unknown, + afterValue: unknown, +) { + if (!Array.isArray(beforeValue) || !Array.isArray(afterValue)) { + return null; + } + + const beforeClaims = beforeValue.filter(isRecord); + const afterClaims = afterValue.filter(isRecord); + const beforeByIdentity = new Map>(); + const afterByIdentity = new Map>(); + + for (const claim of beforeClaims) { + const identity = getIdTokenClaimIdentity(claim); + if (identity) { + beforeByIdentity.set(`${identity.namespace}:${identity.key}`, claim); + } + } + + for (const claim of afterClaims) { + const identity = getIdTokenClaimIdentity(claim); + if (identity) { + afterByIdentity.set(`${identity.namespace}:${identity.key}`, claim); + } + } + + const additions: string[] = []; + const removals: string[] = []; + const updates: string[] = []; + + for (const [identity, afterClaim] of afterByIdentity.entries()) { + const beforeClaim = beforeByIdentity.get(identity); + const displayName = formatIdTokenClaimDisplayName(afterClaim); + + if (!beforeClaim) { + const valueType = + typeof afterClaim.valueType === "string" ? afterClaim.valueType : null; + additions.push( + valueType ? `+ ${displayName} (${valueType})` : `+ ${displayName}`, + ); + continue; + } + + if (auditValueSignature(beforeClaim) === auditValueSignature(afterClaim)) { + continue; + } + + const summary = formatIdTokenClaimChangeSummary(beforeClaim, afterClaim); + if (summary) { + updates.push(summary); + } + } + + for (const [identity, beforeClaim] of beforeByIdentity.entries()) { + if (afterByIdentity.has(identity)) { + continue; + } + const displayName = formatIdTokenClaimDisplayName(beforeClaim); + const valueType = + typeof beforeClaim.valueType === "string" ? beforeClaim.valueType : null; + removals.push( + valueType ? `- ${displayName} (${valueType})` : `- ${displayName}`, + ); + } + + const parts = [...additions, ...removals, ...updates].slice(0, 4); + if (parts.length === 0) { + return null; + } + + if (additions.length + removals.length + updates.length > parts.length) { + parts.push("..."); + } + + return parts.join(", "); +} + export function buildRecentClientChangeDetails( action: string, details: AuditDetails, @@ -126,8 +305,19 @@ export function buildRecentClientChangeDetails( const beforeValue = before[key]; const afterValue = after[key]; + if (key === "id_token_claims") { + const value = summarizeIdTokenClaimArrayChange(beforeValue, afterValue); + if (!value) { + return null; + } + return { + label: getRecentClientFieldLabel(key), + value, + }; + } + if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") { - if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) { + if (auditValueSignature(beforeValue) === auditValueSignature(afterValue)) { return null; } } @@ -161,6 +351,10 @@ export function buildRecentClientChangeDetails( }) .filter((item): item is { label: string; value: string } => Boolean(item)); + if (changes.length === 0) { + return []; + } + return changes.slice(0, 3); } @@ -194,7 +388,12 @@ export function buildRecentClientChanges( detailLabels: buildRecentClientChangeDetails(action, details), } satisfies RecentClientChange; }) - .filter((item): item is RecentClientChange => Boolean(item)) + .filter((item): item is RecentClientChange => { + if (!item) { + return false; + } + return item.detailLabels.length > 0; + }) .sort( (left, right) => new Date(right.timestamp).getTime() - From 79bf1c34967efee18f5f07bcd5633c3d911470c4 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 16 Jun 2026 15:23:20 +0900 Subject: [PATCH 06/32] =?UTF-8?q?orgfront=20URL=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=EB=AA=85=EC=9D=84=20ORGFRONT=5FURL=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 1 + devfront/Dockerfile | 2 ++ devfront/src/features/clients/components/TenantAccessPicker.tsx | 1 - devfront/vite.config.ts | 1 + docker/staging_pull_compose.template.yaml | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index 6b98db23..2200fd2d 100644 --- a/.env.sample +++ b/.env.sample @@ -180,6 +180,7 @@ VITE_OIDC_CLIENT_ID=devfront VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc DEVFRONT_URL=http://localhost:5174 DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback +ORGFRONT_URL=http://localhost:5175 ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback VITE_ORGCHART_URL= diff --git a/devfront/Dockerfile b/devfront/Dockerfile index 32d7cdef..6d18bdcd 100644 --- a/devfront/Dockerfile +++ b/devfront/Dockerfile @@ -14,9 +14,11 @@ COPY devfront ./devfront ARG VITE_DEVFRONT_PUBLIC_URL ARG VITE_OIDC_AUTHORITY ARG VITE_OIDC_CLIENT_ID +ARG ORGFRONT_URL ENV VITE_DEVFRONT_PUBLIC_URL=$VITE_DEVFRONT_PUBLIC_URL ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID +ENV ORGFRONT_URL=$ORGFRONT_URL RUN pnpm install --frozen-lockfile --ignore-scripts diff --git a/devfront/src/features/clients/components/TenantAccessPicker.tsx b/devfront/src/features/clients/components/TenantAccessPicker.tsx index c5d90e8f..a71f2c74 100644 --- a/devfront/src/features/clients/components/TenantAccessPicker.tsx +++ b/devfront/src/features/clients/components/TenantAccessPicker.tsx @@ -17,7 +17,6 @@ type TenantAccessPickerProps = { function resolveOrgFrontBaseUrl() { return ( - import.meta.env.VITE_ORGFRONT_PUBLIC_URL || import.meta.env.ORGFRONT_URL || "http://localhost:5175" ); diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts index ae610f9a..d4c89085 100644 --- a/devfront/vite.config.ts +++ b/devfront/vite.config.ts @@ -17,6 +17,7 @@ const allowedHosts = getAllowedHosts( export default defineConfig( mergeConfig(commonViteConfig, { + envPrefix: ["VITE_", "ORGFRONT_"], cacheDir: process.env.DEVFRONT_VITE_CACHE_DIR ?? "/tmp/baron-sso-devfront-vite-cache", diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index 31ca94e2..977aa841 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -455,6 +455,7 @@ services: VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL:-} VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-} VITE_OIDC_CLIENT_ID: devfront + ORGFRONT_URL: ${ORGFRONT_URL:-} container_name: baron_devfront restart: unless-stopped env_file: From 4b2d9c89b3aca2240cd561855edddb97ec62c88e Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 16 Jun 2026 15:41:39 +0900 Subject: [PATCH 07/32] =?UTF-8?q?=EB=A1=9C=EC=BC=80=EC=9D=BC=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=ED=82=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20lint?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/clients/ClientGeneralPage.tsx | 14 +++++++------- .../clients/components/TenantAccessPicker.tsx | 5 +---- .../developer-access/developerAccessPages.test.ts | 4 +--- .../src/features/overview/recentClientChanges.ts | 15 +++++---------- devfront/src/lib/i18n.test.ts | 15 +++++++++++---- locales/en.toml | 6 ++++++ locales/ko.toml | 6 ++++++ locales/template.toml | 6 ++++++ 8 files changed, 43 insertions(+), 28 deletions(-) diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 6d0c0b51..3ab2cd64 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -2427,13 +2427,13 @@ function ClientGeneralPage() { {tenantAccessRestricted ? (
- + { window.localStorage.setItem("locale", "en"); - expect(getDeveloperAccessPageLabel("client_create")).toBe( - "Add linked app", - ); + expect(getDeveloperAccessPageLabel("client_create")).toBe("Add linked app"); expect(developerAccessPagesToLabel(["overview", "audit"])).toBe( "Overview, Audit Logs", ); diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index b02c34be..3262b59e 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -96,10 +96,7 @@ function getRecentClientFieldLabel(key: string) { "클라이언트 시크릿", ); case "id_token_claims": - return t( - "ui.dev.clients.general.id_token_claims.title", - "Custom Claims", - ); + return t("ui.dev.clients.general.id_token_claims.title", "Custom Claims"); default: return key; } @@ -159,11 +156,7 @@ function formatIdTokenClaimChangeSummary( const afterValueType = typeof afterValue.valueType === "string" ? afterValue.valueType : null; - if ( - beforeValueType && - afterValueType && - beforeValueType !== afterValueType - ) { + if (beforeValueType && afterValueType && beforeValueType !== afterValueType) { return `~ ${beforeDisplayName}: ${beforeValueType} → ${afterValueType}`; } @@ -317,7 +310,9 @@ export function buildRecentClientChangeDetails( } if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") { - if (auditValueSignature(beforeValue) === auditValueSignature(afterValue)) { + if ( + auditValueSignature(beforeValue) === auditValueSignature(afterValue) + ) { return null; } } diff --git a/devfront/src/lib/i18n.test.ts b/devfront/src/lib/i18n.test.ts index a16adc1b..b44d349c 100644 --- a/devfront/src/lib/i18n.test.ts +++ b/devfront/src/lib/i18n.test.ts @@ -10,9 +10,13 @@ describe("i18n", () => { window.localStorage.setItem("locale", "en"); expect(t("ui.dev.request.list.title", "신청 내역")).toBe("Request History"); - expect(t("msg.dev.request.list.approved_count", "총 {{count}}명의 사용자가 승인되었습니다.", { count: 0 })).toBe( - "0 users have been approved.", - ); + expect( + t( + "msg.dev.request.list.approved_count", + "총 {{count}}명의 사용자가 승인되었습니다.", + { count: 0 }, + ), + ).toBe("0 users have been approved."); expect(t("ui.dev.grants.form.title", "직접 부여")).toBe("Direct Grant"); expect( t( @@ -23,7 +27,10 @@ describe("i18n", () => { "Select a user to view their current tenant, email, and phone, then grant developer access immediately.", ); expect( - t("msg.dev.grants.list.description", "현재 부여된 개발자 권한 목록입니다."), + t( + "msg.dev.grants.list.description", + "현재 부여된 개발자 권한 목록입니다.", + ), ).toBe("Current developer access grants."); }); }); diff --git a/locales/en.toml b/locales/en.toml index 99387a15..9abc4ba9 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -2687,6 +2687,12 @@ title = "Direct Grant" [ui.dev.grants.list] title = "Granted Access" +[ui.dev.access_pages] +all = "All" +overview = "Overview" +client_create = "Add linked app" +audit = "Audit Logs" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/locales/ko.toml b/locales/ko.toml index fce1b247..7e1be078 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -2687,6 +2687,12 @@ title = "직접 부여" [ui.dev.grants.list] title = "부여된 권한" +[ui.dev.access_pages] +all = "전체" +overview = "개요" +client_create = "연동 앱 추가" +audit = "감사로그" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/locales/template.toml b/locales/template.toml index 5a508530..978d1ead 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -2687,6 +2687,12 @@ title = "" [ui.dev.grants.list] title = "" +[ui.dev.access_pages] +all = "" +overview = "" +client_create = "" +audit = "" + [ui.dev.header] plane = "" subtitle = "" From d30a324293c54eec2f89fbc66c50c62c3e12a652 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 16 Jun 2026 15:55:14 +0900 Subject: [PATCH 08/32] =?UTF-8?q?recent=20changes=20=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8C=8C=EC=8B=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/overview/recentClientChanges.test.ts | 10 ++++++++++ devfront/src/features/overview/recentClientChanges.ts | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts index 243bfb08..2dee876e 100644 --- a/devfront/src/features/overview/recentClientChanges.test.ts +++ b/devfront/src/features/overview/recentClientChanges.test.ts @@ -89,6 +89,16 @@ describe("recent client changes", () => { { label: "Relation", value: "admins" }, { label: "Subject", value: "User:1" }, ]); + + expect( + buildRecentClientChangeDetails("ADD_RELATION", { + relation: "config_editor", + subject: "User:2", + }), + ).toEqual([ + { label: "Relation", value: "config_editor" }, + { label: "Subject", value: "User:2" }, + ]); }); it("ignores audit object key order changes in update details", () => { diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index 3262b59e..c5930c27 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -257,6 +257,12 @@ export function buildRecentClientChangeDetails( ) { const before = isRecord(details.before) ? details.before : {}; const after = isRecord(details.after) ? details.after : {}; + const sourceDetails = + action === "ADD_RELATION" + ? { ...after, ...details } + : action === "REMOVE_RELATION" + ? { ...before, ...details } + : {}; if (action === "ROTATE_SECRET") { return [ @@ -268,7 +274,7 @@ export function buildRecentClientChangeDetails( } if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { - const source = action === "ADD_RELATION" ? after : before; + const source = sourceDetails; return [ ...(source.relation ? [ From 072a982b5a2c1dff1d8135285a417fb949d88f4d Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 16 Jun 2026 16:03:39 +0900 Subject: [PATCH 09/32] =?UTF-8?q?recent=20changes=20=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/overview/recentClientChanges.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index c5930c27..b331346d 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -274,21 +274,23 @@ export function buildRecentClientChangeDetails( } if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { - const source = sourceDetails; + const source = sourceDetails as Record; + const relation = source.relation; + const subject = source.subject; return [ - ...(source.relation + ...(typeof relation === "string" && relation ? [ { label: getRecentClientFieldLabel("relation"), - value: formatAuditValue(source.relation), + value: formatAuditValue(relation), }, ] : []), - ...(source.subject + ...(typeof subject === "string" && subject ? [ { label: getRecentClientFieldLabel("subject"), - value: formatAuditValue(source.subject), + value: formatAuditValue(subject), }, ] : []), From 2a9ab0ddc50b2c4e89781451a392209b90a55490 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 16:49:14 +0900 Subject: [PATCH 10/32] =?UTF-8?q?userfront:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EA=B8=B0=EB=B3=B8=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?(Personal)=20=ED=85=8C=EB=84=8C=ED=8A=B8=20=EA=B0=80=EC=9E=85,?= =?UTF-8?q?=20=EA=B8=B0=EC=97=85=20=EC=86=8C=EC=86=8D=EC=9D=80=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=EB=AC=B8=EC=9D=98=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EC=B9=B4=EB=93=9C=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 139 +++++++++++++----- 1 file changed, 99 insertions(+), 40 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 20afee5f..e0b9cd37 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:userfront/i18n.dart'; import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; @@ -1788,24 +1789,88 @@ Matters not expressly provided in this Policy are governed by the Company's inte crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_affiliationType == 'AFFILIATE') ...[ - const SizedBox(height: 14), - DropdownButtonFormField( - key: ValueKey(_companyCode ?? 'none'), - initialValue: _companyCode, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.signup.profile.company', + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.25), ), - border: const OutlineInputBorder(), ), - items: _tenants.map((t) { - return DropdownMenuItem( - value: t['slug'], - child: Text(t['name'] ?? t['slug']), - ); - }).toList(), - onChanged: (val) => - setState(() => _companyCode = val), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.business, + color: Theme.of(context) + .colorScheme + .primary, + size: 24, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '기업 소속 가입 안내', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Theme.of(context) + .colorScheme + .primary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '기업/가족사 소속 회원은 바로 가입하는 대신 별도 문의를 통해 가입 및 워크스페이스 연동이 진행됩니다.\n\n아래 버튼을 눌러 담당자에게 가입 문의 이메일을 보내거나 baroncs@baroncs.co.kr로 직접 문의해 주시기 바랍니다.', + style: TextStyle( + fontSize: 13, + height: 1.5, + color: _signupInk + .withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () async { + final Uri emailUri = Uri( + scheme: 'mailto', + path: 'baroncs@baroncs.co.kr', + query: Uri.encodeFull( + 'subject=[Baron SSO] 기업 소속 가입 및 연동 문의', + ), + ); + if (await canLaunchUrl(emailUri)) { + await launchUrl(emailUri); + } + }, + icon: const Icon(Icons.mail_outline, + size: 18), + label: const Text('기업 소속 문의하기'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + vertical: 12), + ), + ), + ], + ), ), ], ], @@ -1814,30 +1879,26 @@ Matters not expressly provided in this Policy are governed by the Company's inte ], ), ), - const SizedBox(height: 18), - _buildProfileFieldGroup( - title: _affiliationType == 'AFFILIATE' - ? tr('ui.userfront.signup.profile.department') - : tr( + if (_affiliationType == 'GENERAL') ...[ + const SizedBox(height: 18), + _buildProfileFieldGroup( + title: tr( + 'ui.userfront.signup.profile.department_optional', + ), + description: '선택 입력 항목입니다.', + isDesktop: isDesktop, + child: TextFormField( + controller: _deptController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: tr( 'ui.userfront.signup.profile.department_optional', ), - description: _affiliationType == 'AFFILIATE' - ? '가족사 사용자는 부서명을 입력해주세요.' - : '선택 입력 항목입니다.', - isDesktop: isDesktop, - child: TextFormField( - controller: _deptController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: _affiliationType == 'AFFILIATE' - ? tr('ui.userfront.signup.profile.department') - : tr( - 'ui.userfront.signup.profile.department_optional', - ), - border: const OutlineInputBorder(), + border: const OutlineInputBorder(), + ), ), ), - ), + ], ], ), ), @@ -2317,10 +2378,8 @@ Matters not expressly provided in this Policy are governed by the Company's inte if (_affiliationType == 'GENERAL') { canGoNext = nameOk; } else { - // AFFILIATE 필수: 이름 + 가족사 선택 + 부서명 - final companyOk = _companyCode != null; - final deptOk = _deptController.text.trim().isNotEmpty; - canGoNext = nameOk && companyOk && deptOk; + // 기업 소속(AFFILIATE)인 경우 직접 가입 대신 문의로 안내하므로 다음 단계 진행을 차단합니다. + canGoNext = false; } } From 95a2730e7126342df17f08772d70cea5b76baf6e Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 16:56:03 +0900 Subject: [PATCH 11/32] =?UTF-8?q?backend:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0=EB=A5=BC=20=ED=86=B5=ED=95=9C=20=EA=B0=80=EC=A1=B1?= =?UTF-8?q?=EC=82=AC(AFFILIATE)=20=EA=B0=95=EC=A0=9C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20=EC=A0=84=EB=A9=B4=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EA=B8=B0=EB=B3=B8=20=EA=B0=9C=EC=9D=B8(Personal)?= =?UTF-8?q?=20=EA=B0=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 70 +++---------------- .../handler/auth_handler_signup_test.go | 10 +-- 2 files changed, 12 insertions(+), 68 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 541db7b3..a9379f0a 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -712,69 +712,17 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { } // 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다. - tenantSlug := strings.TrimSpace(req.TenantSlug) + // 모든 온라인 가입자는 기본적으로 개인(Personal) 테넌트 소속으로 가입합니다. + // 기업/가족사 소속 연동은 별도 문의를 통해 처리되므로 온라인 가입 흐름에서는 제외합니다. + req.AffiliationType = "GENERAL" + slog.Info("[Signup] Forcing AffiliationType to GENERAL (Default personal tenant signup policy)", "email", req.Email) + var tenantID *string - - parts := strings.Split(req.Email, "@") - if len(parts) != 2 { - return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") - } - domainName := parts[1] - - // Check if this domain belongs to a predefined family affiliate - isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) - - // [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose) - if isInternal { - req.AffiliationType = "AFFILIATE" - slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email) - } else { - req.AffiliationType = "GENERAL" - slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email) - } - - if tenantSlug != "" { - // [Security] Cross-check: If domain is NOT internal, they cannot provide a tenantSlug - if !isInternal { - slog.Warn("[Signup] Security violation: non-internal email providing tenantSlug", "email", req.Email) - return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.") - } - - if !affiliateSlugs[strings.ToLower(tenantSlug)] { - return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.") - } - - tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug) - if err == nil && tenant != nil { - if tenant.Status == domain.TenantStatusActive { - slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug) - tenantSlug = tenant.Slug - tenantID = &tenant.ID - } else { - return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.") - } - } else { - slog.Warn("[Signup] Attempted to join non-existent organization", "slug", tenantSlug, "email", req.Email) - return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.") - } - } else { - // If it's a family affiliate domain, they MUST select one of the family companies - if isInternal { - return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.") - } - } - - if tenantID == nil && req.AffiliationType == "AFFILIATE" { - return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.") - } - if tenantID == nil && req.AffiliationType == "GENERAL" { - tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, req.Email) - if err != nil { - return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant") - } - tenantSlug = tenant.Slug - tenantID = &tenant.ID + tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, req.Email) + if err != nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant") } + tenantID = &tenant.ID // Normalize Phone (E.164 형태로 보관) normalizedPhone := domain.NormalizePhoneNumber(req.Phone) diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index 0ee23240..2fdd4ea9 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -116,22 +116,18 @@ func TestSignup_TenantSlugValidation(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) - t.Run("Active Tenant Slug", func(t *testing.T) { + t.Run("Success creates Personal Tenant", func(t *testing.T) { reqBody := domain.SignupRequest{ Email: "user@hanmaceng.co.kr", Password: "StrongPass123!", Name: "Test User", Phone: "010-1234-5678", TermsAccepted: true, - TenantSlug: "hanmac", } body, _ := json.Marshal(reqBody) - validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive} - mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once() - mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe() - mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once() - mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once() + validTenant := &domain.Tenant{ID: "personal-t1", Slug: "personal-slug", Status: domain.TenantStatusActive} + mockTenantSvc.On("RegisterTenant", mock.Anything, "Personal - user@hanmaceng.co.kr", mock.Anything, domain.TenantTypePersonal, "Automatically provisioned personal tenant", []string(nil), (*string)(nil), "").Return(validTenant, nil).Once() mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once() mockRedis.On("Delete", mock.Anything).Return(nil) From b1c853b3c304a53cc902d6ba6e5c9e0d0a04aab3 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 16:59:32 +0900 Subject: [PATCH 12/32] =?UTF-8?q?userfront:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=99=94=EB=A9=B4=20=EB=82=B4=20=EC=86=8C=EC=86=8D?= =?UTF-8?q?=20=EA=B5=AC=EB=B6=84=20=EB=AA=85=EC=B9=AD=20=EA=B0=9C=ED=8E=B8?= =?UTF-8?q?=20(=EC=9D=BC=EB=B0=98/=EA=B0=80=EC=A1=B1=EC=82=AC=20->=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8/=EA=B8=B0=EC=97=85=EC=86=8C=EC=86=8D)=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=A4=EB=AA=85=20=EB=AC=B8=EA=B5=AC=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/assets/translations/en.toml | 4 ++-- userfront/assets/translations/ko.toml | 4 ++-- userfront/assets/translations/template.toml | 4 ++-- userfront/lib/features/auth/presentation/signup_screen.dart | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 1b608e0b..ce551efb 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -1,8 +1,8 @@ [domain] [domain.affiliation] -affiliate = "Affiliate" -general = "General" +affiliate = "Corporate/Affiliate" +general = "Personal" [domain.company] baron = "Baron" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 76b6952a..8723e746 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -1,8 +1,8 @@ [domain] [domain.affiliation] -affiliate = "가족사 임직원" -general = "일반 사용자" +affiliate = "기업 소속" +general = "개인 (Personal)" [domain.company] baron = "바론" diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index f6776236..4aebc2bc 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -1,8 +1,8 @@ [domain] [domain.affiliation] -affiliate = "" -general = "" +affiliate = "Corporate/Affiliate" +general = "Personal" [domain.company] baron = "" diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index e0b9cd37..e265a268 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1743,7 +1743,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte const SizedBox(height: 18), _buildProfileFieldGroup( title: tr('ui.userfront.signup.profile.affiliation_type'), - description: '소속 유형과 회사 정보를 입력합니다.', + description: '개인 가입 혹은 기업 연동을 선택합니다.', isDesktop: isDesktop, trailing: null, child: Column( From 40eaadd88d229fb0a9e7ce43fb18860424ae4317 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:03:20 +0900 Subject: [PATCH 13/32] =?UTF-8?q?userfront:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=8B=A8=EA=B3=84=EC=97=90=EC=84=9C=20=EC=86=8C?= =?UTF-8?q?=EC=86=8D=20=EC=9C=A0=ED=98=95=20=EC=84=A0=ED=83=9D/=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20UI=EB=A5=BC=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=ED=9E=88=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20100%=20=EA=B0=9C=EC=9D=B8=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=ED=99=94=20=EB=B0=8F=20=EA=B8=B0=EC=97=85=20?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20=EB=B0=B0=EB=84=88=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 211 +++++------------- 1 file changed, 57 insertions(+), 154 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index e265a268..846087b8 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1740,165 +1740,74 @@ Matters not expressly provided in this Policy are governed by the Company's inte ), ), ), - const SizedBox(height: 18), - _buildProfileFieldGroup( - title: tr('ui.userfront.signup.profile.affiliation_type'), - description: '개인 가입 혹은 기업 연동을 선택합니다.', - isDesktop: isDesktop, - trailing: null, + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _signupBorder, + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - DropdownButtonFormField( - key: ValueKey(_affiliationType), - initialValue: _affiliationType, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.signup.profile.affiliation_type', + Row( + children: [ + Icon( + Icons.business, + color: Theme.of(context).colorScheme.primary, + size: 20, ), - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: 'GENERAL', - child: Text(tr('domain.affiliation.general')), - ), - DropdownMenuItem( - value: 'AFFILIATE', - child: Text(tr('domain.affiliation.affiliate')), + const SizedBox(width: 8), + Expanded( + child: Text( + '기업/가족사 소속이신가요?', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: _signupInk, + ), + ), ), ], - onChanged: _isAffiliateLocked - ? null - : (val) { - if (val == null) { - return; - } - setState(() { - _affiliationType = val; - if (_affiliationType == 'GENERAL') { - _companyCode = null; - } - }); - }, ), - AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_affiliationType == 'AFFILIATE') ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer - .withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.25), - ), - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon( - Icons.business, - color: Theme.of(context) - .colorScheme - .primary, - size: 24, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - '기업 소속 가입 안내', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: Theme.of(context) - .colorScheme - .primary, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - '기업/가족사 소속 회원은 바로 가입하는 대신 별도 문의를 통해 가입 및 워크스페이스 연동이 진행됩니다.\n\n아래 버튼을 눌러 담당자에게 가입 문의 이메일을 보내거나 baroncs@baroncs.co.kr로 직접 문의해 주시기 바랍니다.', - style: TextStyle( - fontSize: 13, - height: 1.5, - color: _signupInk - .withValues(alpha: 0.8), - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: () async { - final Uri emailUri = Uri( - scheme: 'mailto', - path: 'baroncs@baroncs.co.kr', - query: Uri.encodeFull( - 'subject=[Baron SSO] 기업 소속 가입 및 연동 문의', - ), - ); - if (await canLaunchUrl(emailUri)) { - await launchUrl(emailUri); - } - }, - icon: const Icon(Icons.mail_outline, - size: 18), - label: const Text('기업 소속 문의하기'), - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - vertical: 12), - ), - ), - ], - ), - ), - ], - ], + const SizedBox(height: 8), + Text( + '기업 및 가족사 임직원은 온라인 즉시 가입 대신 별도 연동 문의를 통해 워크스페이스 계정이 발급됩니다.\n\n아래 버튼을 눌러 담당자에게 문의 메일을 발송해 주시기 바랍니다.', + style: TextStyle( + fontSize: 12, + height: 1.45, + color: _signupInk.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () async { + final Uri emailUri = Uri( + scheme: 'mailto', + path: 'baroncs@baroncs.co.kr', + query: Uri.encodeFull( + 'subject=[Baron SSO] 기업 소속 가입 및 연동 문의', + ), + ); + if (await canLaunchUrl(emailUri)) { + await launchUrl(emailUri); + } + }, + icon: const Icon(Icons.mail_outline, size: 16), + label: const Text('기업 소속 문의하기'), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 10), ), ), ], ), ), - if (_affiliationType == 'GENERAL') ...[ - const SizedBox(height: 18), - _buildProfileFieldGroup( - title: tr( - 'ui.userfront.signup.profile.department_optional', - ), - description: '선택 입력 항목입니다.', - isDesktop: isDesktop, - child: TextFormField( - controller: _deptController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.signup.profile.department_optional', - ), - border: const OutlineInputBorder(), - ), - ), - ), - ], ], ), ), @@ -2374,13 +2283,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte canGoNext = true; } if (_currentStep == 3) { - final nameOk = _nameController.text.trim().isNotEmpty; - if (_affiliationType == 'GENERAL') { - canGoNext = nameOk; - } else { - // 기업 소속(AFFILIATE)인 경우 직접 가입 대신 문의로 안내하므로 다음 단계 진행을 차단합니다. - canGoNext = false; - } + canGoNext = _nameController.text.trim().isNotEmpty; } return Scaffold( From 2cd2ce4c02eb4a974688487db806b41735927111 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:13:45 +0900 Subject: [PATCH 14/32] =?UTF-8?q?userfront:=20=EC=86=8C=EC=86=8D=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=8B=A8=EA=B3=84=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EC=8B=9C=20=EA=B2=80=EC=A6=9D=EB=90=9C=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=8C=90=EC=A0=95=ED=95=98=EC=97=AC=20=EA=B0=80?= =?UTF-8?q?=EC=A1=B1=EC=82=AC=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=AC=B8=EC=9D=98=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=95=EC=A0=9C=20=EB=85=B8=EC=B6=9C=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=B0=A8=EB=8B=A8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 186 ++++++++++-------- 1 file changed, 104 insertions(+), 82 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 846087b8..70c926be 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1712,104 +1712,126 @@ Matters not expressly provided in this Policy are governed by the Company's inte horizontal: isDesktop ? 32 : 20, vertical: isDesktop ? 32 : 24, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.profile.title'), - style: TextStyle( - fontSize: isDesktop ? 28 : 20, - fontWeight: FontWeight.w700, - height: 1.25, - color: _signupInk, - ), - ), - const SizedBox(height: 12), - _buildProfileInfoNoticeCard(isDesktop: isDesktop), - SizedBox(height: isDesktop ? 28 : 24), - _buildProfileFieldGroup( - title: tr('ui.userfront.signup.profile.name'), - description: '기본 정보', - isDesktop: isDesktop, - child: TextFormField( - controller: _nameController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.name'), - border: const OutlineInputBorder(), - ), - ), - ), - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: _signupSurface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _signupBorder, - ), - ), - child: Column( + child: _affiliationType == 'AFFILIATE' + ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Icon( - Icons.business, - color: Theme.of(context).colorScheme.primary, - size: 20, + Text( + '가족사 임직원 가입 안내', + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.25), ), - const SizedBox(width: 8), - Expanded( - child: Text( - '기업/가족사 소속이신가요?', + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.business, + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '기업 및 가족사 임직원 대상자입니다.', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + '귀하의 이메일 도메인은 기업/가족사 임직원 대상자입니다. 온라인 직접 가입 대신 별도의 조인 문의를 통해 가입 및 워크스페이스 연동이 진행됩니다.\n\n아래 버튼을 눌러 담당자에게 가입 연동 문의 메일을 발송하시거나 사내 시스템 관리자에게 문의해 주시기 바랍니다.', style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: _signupInk, + fontSize: 13, + height: 1.55, + color: _signupInk.withValues(alpha: 0.8), ), ), - ), - ], + const SizedBox(height: 20), + FilledButton.icon( + onPressed: () async { + final Uri emailUri = Uri( + scheme: 'mailto', + path: 'baroncs@baroncs.co.kr', + query: Uri.encodeFull( + 'subject=[Baron SSO] 기업 소속 가입 및 연동 문의', + ), + ); + if (await canLaunchUrl(emailUri)) { + await launchUrl(emailUri); + } + }, + icon: const Icon(Icons.mail_outline, size: 18), + label: const Text('기업 소속 문의하기'), + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + vertical: 14), + ), + ), + ], + ), ), - const SizedBox(height: 8), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Text( - '기업 및 가족사 임직원은 온라인 즉시 가입 대신 별도 연동 문의를 통해 워크스페이스 계정이 발급됩니다.\n\n아래 버튼을 눌러 담당자에게 문의 메일을 발송해 주시기 바랍니다.', + tr('msg.userfront.signup.profile.title'), style: TextStyle( - fontSize: 12, - height: 1.45, - color: _signupInk.withValues(alpha: 0.7), + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, ), ), const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () async { - final Uri emailUri = Uri( - scheme: 'mailto', - path: 'baroncs@baroncs.co.kr', - query: Uri.encodeFull( - 'subject=[Baron SSO] 기업 소속 가입 및 연동 문의', - ), - ); - if (await canLaunchUrl(emailUri)) { - await launchUrl(emailUri); - } - }, - icon: const Icon(Icons.mail_outline, size: 16), - label: const Text('기업 소속 문의하기'), - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + _buildProfileInfoNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildProfileFieldGroup( + title: tr('ui.userfront.signup.profile.name'), + description: '기본 정보', + isDesktop: isDesktop, + child: TextFormField( + controller: _nameController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: tr('ui.userfront.signup.profile.name'), + border: const OutlineInputBorder(), ), - padding: const EdgeInsets.symmetric(vertical: 10), ), ), ], ), - ), - ], - ), ), ), ), @@ -2283,7 +2305,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte canGoNext = true; } if (_currentStep == 3) { - canGoNext = _nameController.text.trim().isNotEmpty; + canGoNext = _affiliationType == 'GENERAL' && _nameController.text.trim().isNotEmpty; } return Scaffold( From d3ae4c7e3868bc039c813477bcfd570b4cf50743 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:13:53 +0900 Subject: [PATCH 15/32] =?UTF-8?q?userfront:=20=EA=B0=9C=EC=9D=B8/=EA=B8=B0?= =?UTF-8?q?=EC=97=85=20=EC=86=8C=EC=86=8D=20=EC=9D=B4=EC=A0=95=ED=91=9C=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/assets/translations/en.toml | 4 ++-- userfront/assets/translations/ko.toml | 4 ++-- userfront/assets/translations/template.toml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index ce551efb..1b608e0b 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -1,8 +1,8 @@ [domain] [domain.affiliation] -affiliate = "Corporate/Affiliate" -general = "Personal" +affiliate = "Affiliate" +general = "General" [domain.company] baron = "Baron" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 8723e746..76b6952a 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -1,8 +1,8 @@ [domain] [domain.affiliation] -affiliate = "기업 소속" -general = "개인 (Personal)" +affiliate = "가족사 임직원" +general = "일반 사용자" [domain.company] baron = "바론" diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 4aebc2bc..f6776236 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -1,8 +1,8 @@ [domain] [domain.affiliation] -affiliate = "Corporate/Affiliate" -general = "Personal" +affiliate = "" +general = "" [domain.company] baron = "" From 721f8475b312f349efba3e95a4708c08a86e0d7e Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:17:48 +0900 Subject: [PATCH 16/32] =?UTF-8?q?userfront:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=8B=A8=EA=B3=84=EC=97=90=EC=84=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=90=EC=A0=95=20=EC=97=86=EC=9D=B4=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EB=B7=B0=EB=A5=BC=20=EB=85=B8=EC=B6=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EA=B8=B0=EC=97=85=20=EC=9E=84=EC=A7=81=EC=9B=90=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=B0=B0=EB=84=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 186 ++++++++---------- 1 file changed, 82 insertions(+), 104 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 70c926be..b043e6df 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1712,126 +1712,104 @@ Matters not expressly provided in this Policy are governed by the Company's inte horizontal: isDesktop ? 32 : 20, vertical: isDesktop ? 32 : 24, ), - child: _affiliationType == 'AFFILIATE' - ? Column( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.profile.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildProfileInfoNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildProfileFieldGroup( + title: tr('ui.userfront.signup.profile.name'), + description: '기본 정보', + isDesktop: isDesktop, + child: TextFormField( + controller: _nameController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: tr('ui.userfront.signup.profile.name'), + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _signupBorder, + ), + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - '가족사 임직원 가입 안내', - style: TextStyle( - fontSize: isDesktop ? 28 : 20, - fontWeight: FontWeight.w700, - height: 1.25, - color: _signupInk, - ), - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer - .withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.25), + Row( + children: [ + Icon( + Icons.business, + color: Theme.of(context).colorScheme.primary, + size: 20, ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon( - Icons.business, - color: Theme.of(context).colorScheme.primary, - size: 24, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - '기업 및 가족사 임직원 대상자입니다.', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - ), - const SizedBox(height: 14), - Text( - '귀하의 이메일 도메인은 기업/가족사 임직원 대상자입니다. 온라인 직접 가입 대신 별도의 조인 문의를 통해 가입 및 워크스페이스 연동이 진행됩니다.\n\n아래 버튼을 눌러 담당자에게 가입 연동 문의 메일을 발송하시거나 사내 시스템 관리자에게 문의해 주시기 바랍니다.', + const SizedBox(width: 8), + Expanded( + child: Text( + '기업/가족사 소속이신가요?', style: TextStyle( - fontSize: 13, - height: 1.55, - color: _signupInk.withValues(alpha: 0.8), + fontWeight: FontWeight.bold, + fontSize: 14, + color: _signupInk, ), ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: () async { - final Uri emailUri = Uri( - scheme: 'mailto', - path: 'baroncs@baroncs.co.kr', - query: Uri.encodeFull( - 'subject=[Baron SSO] 기업 소속 가입 및 연동 문의', - ), - ); - if (await canLaunchUrl(emailUri)) { - await launchUrl(emailUri); - } - }, - icon: const Icon(Icons.mail_outline, size: 18), - label: const Text('기업 소속 문의하기'), - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - vertical: 14), - ), - ), - ], - ), + ), + ], ), - ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + const SizedBox(height: 8), Text( - tr('msg.userfront.signup.profile.title'), + '기업 및 가족사 임직원은 온라인 즉시 가입 대신 별도 연동 문의를 통해 워크스페이스 계정이 발급됩니다.\n\n해당하시는 경우, 아래 버튼을 눌러 담당자에게 문의 메일을 발송해 주시기 바랍니다.', style: TextStyle( - fontSize: isDesktop ? 28 : 20, - fontWeight: FontWeight.w700, - height: 1.25, - color: _signupInk, + fontSize: 12, + height: 1.45, + color: _signupInk.withValues(alpha: 0.7), ), ), const SizedBox(height: 12), - _buildProfileInfoNoticeCard(isDesktop: isDesktop), - SizedBox(height: isDesktop ? 28 : 24), - _buildProfileFieldGroup( - title: tr('ui.userfront.signup.profile.name'), - description: '기본 정보', - isDesktop: isDesktop, - child: TextFormField( - controller: _nameController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.name'), - border: const OutlineInputBorder(), + OutlinedButton.icon( + onPressed: () async { + final Uri emailUri = Uri( + scheme: 'mailto', + path: 'baroncs@baroncs.co.kr', + query: Uri.encodeFull( + 'subject=[Baron SSO] 기업 소속 가입 및 연동 문의', + ), + ); + if (await canLaunchUrl(emailUri)) { + await launchUrl(emailUri); + } + }, + icon: const Icon(Icons.mail_outline, size: 16), + label: const Text('기업 소속 문의하기'), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), ), + padding: const EdgeInsets.symmetric(vertical: 10), ), ), ], ), + ), + ], + ), ), ), ), @@ -2305,7 +2283,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte canGoNext = true; } if (_currentStep == 3) { - canGoNext = _affiliationType == 'GENERAL' && _nameController.text.trim().isNotEmpty; + canGoNext = _nameController.text.trim().isNotEmpty; } return Scaffold( From 544aa4472ab7fd361daf9bf52c9b72bc1f699f64 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:21:53 +0900 Subject: [PATCH 17/32] =?UTF-8?q?userfront:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=8B=A8=EA=B3=84=EC=97=90=EC=84=9C=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=8C=90=EC=A0=95=20=EB=B0=8F=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=91=EC=84=B1=20=EB=B2=84=ED=8A=BC=EC=9D=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=EC=8B=9D=20=EA=B8=B0=EC=97=85=20=EA=B0=80=EC=9E=85=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=B0=B0=EB=84=88=EB=A1=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index b043e6df..f4713391 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1775,36 +1775,13 @@ Matters not expressly provided in this Policy are governed by the Company's inte ), const SizedBox(height: 8), Text( - '기업 및 가족사 임직원은 온라인 즉시 가입 대신 별도 연동 문의를 통해 워크스페이스 계정이 발급됩니다.\n\n해당하시는 경우, 아래 버튼을 눌러 담당자에게 문의 메일을 발송해 주시기 바랍니다.', + '기업 및 가족사 임직원은 온라인 즉시 가입 대신 별도 연동 문의를 통해 워크스페이스 계정이 발급됩니다.\n\n해당하시는 경우, 가입을 중단하시고 사내 관리자 또는 담당자(baroncs@baroncs.co.kr)에게 문의해 주시기 바랍니다.', style: TextStyle( fontSize: 12, height: 1.45, color: _signupInk.withValues(alpha: 0.7), ), ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () async { - final Uri emailUri = Uri( - scheme: 'mailto', - path: 'baroncs@baroncs.co.kr', - query: Uri.encodeFull( - 'subject=[Baron SSO] 기업 소속 가입 및 연동 문의', - ), - ); - if (await canLaunchUrl(emailUri)) { - await launchUrl(emailUri); - } - }, - icon: const Icon(Icons.mail_outline, size: 16), - label: const Text('기업 소속 문의하기'), - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(vertical: 10), - ), - ), ], ), ), From ac3226e9399eec2166eafce6b9a7b52128b5d768 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:29:03 +0900 Subject: [PATCH 18/32] =?UTF-8?q?backend:=20=EA=B0=9C=EC=9D=B8=20=ED=85=8C?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=9C=20=EC=8A=AC=EB=9F=AC=EA=B7=B8=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20503=20=EC=98=A4=EB=A5=98=20=EC=9B=90=EC=B2=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20=EC=BD=94=EB=93=9C=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/tenant_assignment_policy.go | 3 ++ .../handler/tenant_assignment_policy_test.go | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 backend/internal/handler/tenant_assignment_policy_test.go diff --git a/backend/internal/handler/tenant_assignment_policy.go b/backend/internal/handler/tenant_assignment_policy.go index fa381207..63678875 100644 --- a/backend/internal/handler/tenant_assignment_policy.go +++ b/backend/internal/handler/tenant_assignment_policy.go @@ -135,6 +135,9 @@ func createPersonalTenantForUser(ctx context.Context, tenantService service.Tena normalizedEmail = "user" } slug := "personal-" + strings.ReplaceAll(uuid.NewString(), "-", "") + if len(slug) > 32 { + slug = slug[:32] + } tenant, err := tenantService.RegisterTenant( ctx, fmt.Sprintf("Personal - %s", normalizedEmail), diff --git a/backend/internal/handler/tenant_assignment_policy_test.go b/backend/internal/handler/tenant_assignment_policy_test.go new file mode 100644 index 00000000..eb53acb7 --- /dev/null +++ b/backend/internal/handler/tenant_assignment_policy_test.go @@ -0,0 +1,49 @@ +package handler + +import ( + "context" + "strings" + "testing" + + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/utils" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestCreatePersonalTenantForUser_SlugLength(t *testing.T) { + mockTenantService := &MockTenantService{} + ctx := context.Background() + + var capturedSlug string + mockTenantService.On( + "RegisterTenant", + ctx, + "Personal - user@example.com", + mock.AnythingOfType("string"), + domain.TenantTypePersonal, + "Automatically provisioned personal tenant", + []string(nil), + (*string)(nil), + "", + ).Run(func(args mock.Arguments) { + capturedSlug = args.String(2) + }).Return(&domain.Tenant{ + ID: "personal-tenant-id", + Slug: "personal-slug", + Name: "Personal - user@example.com", + }, nil) + + tenant, err := createPersonalTenantForUser(ctx, mockTenantService, "user@example.com") + assert.NoError(t, err) + assert.NotNil(t, tenant) + + // Ensure the generated slug is strictly 32 characters or less + assert.LessOrEqual(t, len(capturedSlug), 32) + assert.True(t, strings.HasPrefix(capturedSlug, "personal-")) + + // Ensure that the captured slug actually passes ValidateSlug! + valid, msg := utils.ValidateSlug(capturedSlug) + assert.True(t, valid, "Slug must be valid: "+msg) +} From b1a8df34438e92192a5a8eacd80cf48fe9463914 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:30:39 +0900 Subject: [PATCH 19/32] =?UTF-8?q?userfront:=20=EA=B8=B0=EC=97=85=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=95=88=EB=82=B4=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EB=82=B4=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=ED=9A=8D=20=EA=B0=9C=EC=A0=95=EC=95=88?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A0=95=EB=B0=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- userfront/lib/features/auth/presentation/signup_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index f4713391..7b98c01c 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1775,7 +1775,7 @@ Matters not expressly provided in this Policy are governed by the Company's inte ), const SizedBox(height: 8), Text( - '기업 및 가족사 임직원은 온라인 즉시 가입 대신 별도 연동 문의를 통해 워크스페이스 계정이 발급됩니다.\n\n해당하시는 경우, 가입을 중단하시고 사내 관리자 또는 담당자(baroncs@baroncs.co.kr)에게 문의해 주시기 바랍니다.', + '기업 및 가족사 임직원은 연동 문의가 필요합니다.\n\n해당하시는 경우, 사내 관리자 또는 담당자(baroncs@baroncs.co.kr)에게 문의해 주시기 바랍니다.', style: TextStyle( fontSize: 12, height: 1.45, From 26c4666a891baa9f4cff7ed19c87b173d60370c0 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:39:25 +0900 Subject: [PATCH 20/32] =?UTF-8?q?backend:=20=EC=9D=BC=EB=B0=98=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=9E=84=EC=9D=98=EC=9D=98=20=EA=B0=9C?= =?UTF-8?q?=EB=B3=84=20=ED=85=8C=EB=84=8C=ED=8A=B8=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EC=9D=B4=EB=AF=B8=20=EC=8B=9C=EB=93=9C?= =?UTF-8?q?=EB=90=9C=20shared=20'personal'=20=ED=85=8C=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=95=A0=EB=8B=B9=20=EA=B5=AC=ED=98=84=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 10 +++++++--- backend/internal/handler/auth_handler_signup_test.go | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index a9379f0a..8bee99d9 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -718,9 +718,13 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { slog.Info("[Signup] Forcing AffiliationType to GENERAL (Default personal tenant signup policy)", "email", req.Email) var tenantID *string - tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, req.Email) - if err != nil { - return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant") + tenant, err := h.TenantService.GetTenantBySlug(c.Context(), "personal") + if err != nil || tenant == nil { + // Fallback: 만약 시드된 personal 테넌트가 없을 경우 개인별 테넌트를 자동 생성합니다. + tenant, err = createPersonalTenantForUser(c.Context(), h.TenantService, req.Email) + if err != nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "failed to resolve personal tenant") + } } tenantID = &tenant.ID diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index 2fdd4ea9..7d1e479e 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -127,6 +127,7 @@ func TestSignup_TenantSlugValidation(t *testing.T) { body, _ := json.Marshal(reqBody) validTenant := &domain.Tenant{ID: "personal-t1", Slug: "personal-slug", Status: domain.TenantStatusActive} + mockTenantSvc.On("GetTenantBySlug", mock.Anything, "personal").Return((*domain.Tenant)(nil), assert.AnError).Once() mockTenantSvc.On("RegisterTenant", mock.Anything, "Personal - user@hanmaceng.co.kr", mock.Anything, domain.TenantTypePersonal, "Automatically provisioned personal tenant", []string(nil), (*string)(nil), "").Return(validTenant, nil).Once() mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once() mockRedis.On("Delete", mock.Anything).Return(nil) From c990bd591b681e6085f76ee548f363e488505b7d Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 16 Jun 2026 17:53:24 +0900 Subject: [PATCH 21/32] =?UTF-8?q?adminfront:=20=EA=B6=8C=ED=95=9C=EB=B6=80?= =?UTF-8?q?=EC=97=AC=20=EC=84=B8=EB=B6=80=20=ED=83=AD=EC=97=90=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=EC=9B=8D=EC=8A=A4=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C(worksmobile=5Fviewers/managers)=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EA=B3=A0,=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EA=B6=8C=ED=95=9C=20=EB=B6=80=EC=97=AC=20?= =?UTF-8?q?=EC=9E=90=EA=B2=A9=EC=9D=84=20Super=20Admin=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=8A=B9=EA=B2=A9=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coverage/adminTenantTabs.test.tsx | 2 +- .../TenantFineGrainedPermissionsTab.tsx | 73 +++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/adminfront/src/features/coverage/adminTenantTabs.test.tsx b/adminfront/src/features/coverage/adminTenantTabs.test.tsx index 2ec4b09f..efa5ea33 100644 --- a/adminfront/src/features/coverage/adminTenantTabs.test.tsx +++ b/adminfront/src/features/coverage/adminTenantTabs.test.tsx @@ -61,7 +61,7 @@ const users = [ id: "user-owner", name: "Owner User", email: "owner@example.com", - role: "tenant_admin", + role: "super_admin", status: "active", }, { diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx index 779237b1..c7dc5c80 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx @@ -31,13 +31,13 @@ import { import { toast } from "../../../components/ui/use-toast"; import { addTenantRelation, + fetchMe, fetchTenantRelations, fetchUsers, removeTenantRelation, type TenantRelation, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -import { useTenantPermission } from "../hooks/useTenantPermission"; interface TenantFineGrainedPermissionsTabProps { tenantIdProp?: string; @@ -48,8 +48,11 @@ export function TenantFineGrainedPermissionsTab({ }: TenantFineGrainedPermissionsTabProps = {}) { const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const tenantId = tenantIdProp || tenantIdParam || ""; - const { hasPermission } = useTenantPermission(tenantId); - const isWritable = hasPermission("manage_admins"); + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + const isWritable = profile?.role === "super_admin"; const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -75,7 +78,13 @@ export function TenantFineGrainedPermissionsTab({ > = {}; for (const user of relationsQuery.data) { initialMap[user.userId] = {}; - const tabs = ["profile", "permissions", "organization", "schema"]; + const tabs = [ + "profile", + "permissions", + "organization", + "schema", + "worksmobile", + ]; for (const tab of tabs) { const isWrite = user.relations.includes(`${tab}_managers`); const isRead = user.relations.includes(`${tab}_viewers`); @@ -337,6 +346,12 @@ export function TenantFineGrainedPermissionsTab({ {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} + + {t( + "ui.admin.tenants.detail.tab_worksmobile", + "네이버웍스 연동", + )} + {t("ui.common.action", "작업")} @@ -346,7 +361,7 @@ export function TenantFineGrainedPermissionsTab({ {relations.length === 0 ? ( {t( @@ -387,6 +402,14 @@ export function TenantFineGrainedPermissionsTab({ ? "read" : "none"; + const worksmobileVal = user.relations.includes( + "worksmobile_managers", + ) + ? "write" + : user.relations.includes("worksmobile_viewers") + ? "read" + : "none"; + const curProfileVal = localTenantPermissions[user.userId]?.profile ?? profileVal; @@ -398,6 +421,9 @@ export function TenantFineGrainedPermissionsTab({ organizationVal; const curSchemaVal = localTenantPermissions[user.userId]?.schema ?? schemaVal; + const curWorksmobileVal = + localTenantPermissions[user.userId]?.worksmobile ?? + worksmobileVal; return ( + + +