diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 77d23ab5..b42827f9 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -60,6 +60,12 @@ jobs: node tools/i18n-scanner/report.js cat reports/i18n-report.txt + - name: i18n value quality check + run: | + mkdir -p reports + node tools/i18n-scanner/value-check.js + cat reports/i18n-value-report.txt + - name: Setup Go uses: actions/setup-go@v5 with: diff --git a/Makefile b/Makefile index 8466a2b2..08b09822 100644 --- a/Makefile +++ b/Makefile @@ -115,7 +115,7 @@ PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium endif -.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests +.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests CODE_CHECK_TEST_JOBS ?= 1 PLAYWRIGHT_WORKERS ?= 1 @@ -124,7 +124,7 @@ FLUTTER_TEST_CONCURRENCY ?= 1 code-check: code-check-lint code-check-test-jobs @echo "code-check complete." -code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint +code-check-lint: code-check-i18n code-check-i18n-values code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-test-jobs: @echo "==> run CI-equivalent test jobs (parallel)" @@ -142,6 +142,12 @@ code-check-i18n: node tools/i18n-scanner/report.js @cat reports/i18n-report.txt +code-check-i18n-values: + @echo "==> i18n value quality check" + @mkdir -p reports + node tools/i18n-scanner/value-check.js + @cat reports/i18n-value-report.txt + code-check-go-lint: @echo "==> go lint/format check" @if command -v golangci-lint >/dev/null 2>&1; then \ diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 4090e8d5..6b2696f2 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -987,7 +987,7 @@ type = "Type" type_boolean = "Boolean" type_date = "Date" type_number = "Number" -type_text = "Text" +type_text = "Text Value" validation_placeholder = "Regex Pattern (Optional)" [ui.admin.tenants.sub] @@ -1340,7 +1340,7 @@ add_title = "Add Identity Provider" add_btn = "Add Provider" [ui.dev.clients.general.identity] -description = "Description" +description = "Application Description" description_placeholder = "Description Placeholder" logo = "App Logo URL" logo_placeholder = "https://example.com/logo.png" @@ -1360,7 +1360,7 @@ name_placeholder = "e.g. profile" title = "Scopes" [ui.dev.clients.general.scopes.table] -description = "Description" +description = "Scope Description" mandatory = "Mandatory" name = "Scope Name" delete = "Delete" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index eaa5e8e2..1c1292e8 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -103,9 +103,9 @@ count = "총 {{count}}개 API 키" [msg.admin.audit] empty = "아직 수집된 감사 로그가 없습니다." -end = "End of audit feed" -load_error = "Error loading logs: {{error}}" -loading = "Loading audit logs..." +end = "감사 로그의 마지막입니다." +load_error = "감사 로그를 불러오지 못했습니다: {{error}}" +loading = "감사 로그를 불러오는 중..." subtitle = "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다." [msg.admin.audit.filters] @@ -329,9 +329,9 @@ subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합 deleted = "앱이 삭제되었습니다." delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." delete_error = "삭제 실패: {{error}}" -load_error = "Error loading clients: {{error}}" -loading = "Loading apps..." -showing = "Showing {{shown}} of {{total}} apps" +load_error = "앱 정보를 불러오지 못했습니다: {{error}}" +loading = "앱 정보를 불러오는 중..." +showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." [msg.dev.clients.consents] empty = "조회된 동의 내역이 없습니다." @@ -987,7 +987,7 @@ type = "타입" type_boolean = "Boolean" type_date = "Date" type_number = "Number" -type_text = "Text" +type_text = "텍스트" validation_placeholder = "정규표현식 (선택 사항)" [ui.admin.tenants.sub] @@ -1298,8 +1298,8 @@ user = "사용자" [ui.dev.clients.details] [ui.dev.clients.details.credentials] -client_id = "Client ID" -client_secret = "Client Secret" +client_id = "클라이언트 ID" +client_secret = "클라이언트 시크릿" title = "앱 자격 증명" [ui.dev.clients.details.endpoints] @@ -1308,7 +1308,7 @@ title = "OIDC 엔드포인트" [ui.dev.clients.details.redirect] callback_label = "인증 콜백 URL" -label = "Redirect URIs" +label = "리디렉션 URI" placeholder = "https://your-app.com/callback, http://localhost:3000/auth/callback" save = "Redirect URIs 저장" title = "리디렉션 URI 설정" @@ -1345,11 +1345,11 @@ logo = "앱 로고 URL" logo_placeholder = "https://example.com/logo.png" logo_preview = "로고 미리보기" name = "앱 이름" -name_placeholder = "My Awesome Application" +name_placeholder = "예: 멋진 애플리케이션" title = "애플리케이션 정보" [ui.dev.clients.general.redirect] -label = "Redirect URIs" +label = "리디렉션 URI" placeholder = "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)" [ui.dev.clients.general.scopes] @@ -1401,7 +1401,7 @@ profile = "기본 프로필 정보 접근" [ui.dev.clients.table] actions = "액션" application = "애플리케이션" -client_id = "Client ID" +client_id = "클라이언트 ID" created_at = "생성일" status = "상태" type = "유형" @@ -1571,7 +1571,7 @@ qr_scan = "QR 스캔" [ui.userfront.profile] department_empty = "소속 정보 없음" manage = "프로필 관리" -user_fallback = "User" +user_fallback = "사용자" [ui.userfront.profile.field] affiliation = "구분" diff --git a/locales/en.toml b/locales/en.toml index e1002e87..d100464d 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -44,24 +44,24 @@ slow_down = "Requests are too frequent. Please try again shortly." [err.userfront] [err.userfront.auth_proxy] -consent_accept = "Consent Accept" -consent_fetch = "Consent Fetch" -consent_reject = "Consent Reject" -linked_app_revoke = "Linked App Revoke" -login_failed = "Login Failed" +consent_accept = "Failed to accept the consent request." +consent_fetch = "Failed to load consent details." +consent_reject = "Failed to reject the consent request." +linked_app_revoke = "Failed to revoke the linked application." +login_failed = "Login failed." oidc_accept = "OIDC Accept" -password_reset_complete = "Password Reset Complete" -password_reset_init = "Password Reset Init" +password_reset_complete = "Failed to complete the password reset." +password_reset_init = "Failed to start the password reset." [err.userfront.profile] -load_failed = "Load Failed" +load_failed = "Failed to load the profile." password_change_failed = "Password Change Failed" -send_code_failed = "Send Code Failed" -update_failed = "Update Failed" -verify_code_failed = "Verify Code Failed" +send_code_failed = "Failed to send the verification code." +update_failed = "Failed to update the profile." +verify_code_failed = "Verification failed." [err.userfront.session] -missing = "Missing" +missing = "No active session was found." [msg] @@ -78,40 +78,40 @@ forbidden = "You do not have permission to perform this action." [msg.admin.api_keys] [msg.admin.api_keys.create] -error = "Error" -name_required = "Name Required" -scope_required = "Scope Required" -scopes_count = "Scopes Count" -scopes_hint = "Scopes Hint" -subtitle = "Subtitle" +error = "Failed to create the API key." +name_required = "Name is required." +scope_required = "Select at least one scope." +scopes_count = "{{count}} scopes will be assigned." +scopes_hint = "Choose the scopes to grant to this API key." +subtitle = "Create and issue an API key for machine-to-machine communication." [msg.admin.api_keys.create.success] -copy_hint = "Copy Hint" -notice = "Notice" -notice_emphasis = "Notice Emphasis" -notice_suffix = "Notice Suffix" +copy_hint = "Copy the secret now. It will not be shown again." +notice = "The generated secret is displayed only once." +notice_emphasis = "Store it in a secure location." +notice_suffix = "Rotate the key immediately if you think it has been exposed." [msg.admin.api_keys.list] -delete_confirm = "Delete Confirm" -empty = "Empty" -fetch_error = "Fetch Error" -subtitle = "Subtitle" +delete_confirm = "Are you sure you want to delete this API key?" +empty = "No API keys have been issued yet." +fetch_error = "Failed to load the API key list." +subtitle = "View and manage the API keys issued for server-to-server communication." [msg.admin.api_keys.list.registry] -count = "Count" +count = "{{count}} API keys loaded." [msg.admin.audit] -empty = "Empty" +empty = "No audit logs have been collected yet." end = "End of audit feed" load_error = "Error loading logs: {{error}}" loading = "Loading audit logs..." -subtitle = "Subtitle" +subtitle = "Review command-driven ClickHouse audit logs from the admin workspace." [msg.admin.audit.filters] -empty = "Empty" +empty = "No filters applied." [msg.admin.audit.registry] -count = "Count" +count = "{{count}} logs loaded." [msg.admin.groups] @@ -120,32 +120,32 @@ description = "Adds a new organization unit such as a department or team." title = "Create New Organization Unit" [msg.admin.groups.list] -create_error = "Create Failed" -create_success = "Create Success" -delete_confirm = "Delete Confirm" -delete_error = "Delete Error" -delete_success = "Delete Success" -empty = "Empty" +create_error = "Failed to create the organization unit." +create_success = "Organization unit created successfully." +delete_confirm = "Are you sure you want to delete this organization unit?" +delete_error = "Failed to delete the organization unit." +delete_success = "Organization unit deleted successfully." +empty = "No organization units have been registered yet." import_error = "Import Error" import_success = "Import Success" loading = "Loading..." -subtitle = "Subtitle" +subtitle = "Manage departments and teams under the current tenant." [msg.admin.groups.members] -add_success = "Add Success" -count = "Count" -empty = "Empty" -remove_confirm = "Remove Confirm" -remove_success = "Remove Success" -title = "Title" +add_success = "Member added successfully." +count = "{{count}} members loaded." +empty = "No members are assigned to this organization unit." +remove_confirm = "Are you sure you want to remove this member?" +remove_success = "Member removed successfully." +title = "Member Management" [msg.admin.groups.prompt] -user_id = "User Id" +user_id = "Enter the user's UUID to add:" [msg.admin.groups.roles] assign_success = "Assign Success" -description = "Description" -empty = "Empty" +description = "Assign or revoke roles for members of this organization unit." +empty = "No roles have been assigned yet." remove_confirm = "Are you sure you want to revoke this role?" remove_success = "Role revoked successfully." @@ -153,8 +153,8 @@ remove_success = "Role revoked successfully." subtitle = "Tenant isolation & least privilege by default" [msg.admin.notice] -idp_policy = "IDP Policy" -scope = "Scope" +idp_policy = "IDP management keys are only used through server-side wrapper APIs with audit logging and rate limits enabled." +scope = "Administrative features are exposed only within the /admin namespace." [msg.admin.org] hover_member_info = "Hover to see member details." @@ -163,19 +163,19 @@ import_error = "An error occurred during organization chart import." import_success = "Organization chart imported successfully." [msg.admin.overview] -description = "Description" +description = "Review shared metrics and policy status across all tenants in one place." idp_fallback = "Fallback: Descope" idp_primary = "IDP: Ory primary" [msg.admin.overview.playbook] -description = "Description" -idp_body = "IDP Body" +description = "Operational guardrails and architecture decisions for the admin control plane." +idp_body = "All IDP calls are routed through the backend only. Hydra and Kratos admin ports are never exposed publicly." idp_title = "Backend-only IDP access" -tenant_body = "Tenant Body" +tenant_body = "Tenant headers and audit logging are enabled by default and can later be extended with Keto policies." tenant_title = "Tenant isolation" [msg.admin.overview.quick_links] -description = "Description" +description = "Jump to the most frequently used administrative workflows." [msg.admin.overview.summary] audit_events_24h = "24h Audit Events" @@ -184,23 +184,23 @@ policy_gate = "Policy Gate Status" total_tenants = "Total Tenants" [msg.admin.tenants] -approve_confirm = "Approve Confirm" -approve_success = "Approve Success" +approve_confirm = "Do you want to approve this tenant?" +approve_success = "Tenant approved successfully." delete_confirm = "Delete Tenant \"{{name}}\"?" delete_success = "Tenant deleted." -empty = "Empty" -fetch_error = "Fetch Error" +empty = "No tenants have been registered yet." +fetch_error = "Failed to load the tenant list." missing_id = "No Tenant ID." not_found = "Tenant not found." remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?' -subtitle = "Subtitle" +subtitle = "Review registered tenants and manage their current status." [msg.admin.tenants.admins] -add_success = "Add Success" -empty = "Empty" -remove_confirm = "Remove Confirm" -remove_success = "Remove Success" -subtitle = "Subtitle" +add_success = "Tenant admin added successfully." +empty = "No tenant admins are assigned yet." +remove_confirm = "Are you sure you want to remove this tenant admin?" +remove_success = "Tenant admin removed successfully." +subtitle = "Manage the administrators assigned to this tenant." remove_last = "Cannot remove the last admin." remove_self = "Cannot remove yourself." @@ -214,17 +214,17 @@ remove_last = "Cannot remove the last owner." remove_self = "Cannot remove yourself." [msg.admin.tenants.create] -subtitle = "Subtitle" +subtitle = "Enter the minimum required information to create a tenant." [msg.admin.tenants.create.form] domains_help = "Users with these email domains will be automatically assigned to this tenant." [msg.admin.tenants.create.memo] -body = "Body" -subtitle = "Subtitle" +body = "Leave operational notes or policy reminders for this tenant." +subtitle = "Capture internal policy notes for administrators." [msg.admin.tenants.create.profile] -subtitle = "Subtitle" +subtitle = "Set the basic tenant profile information." [msg.admin.tenants.members] desc = "View the list of users belonging to this organization." @@ -232,7 +232,7 @@ empty = "No members found." limit_notice = "Showing members from the first 10 descendant organizations due to size limits." [msg.admin.tenants.registry] -count = "Count" +count = "{{count}} tenants loaded." [msg.admin.tenants.schema] empty = "No custom fields defined. Click \"Add Field\" to begin." @@ -243,8 +243,8 @@ update_success = "Schema updated successfully" forbidden_desc = "Only administrators can access user schema settings." [msg.admin.tenants.sub] -empty = "Empty" -subtitle = "Subtitle" +empty = "No child tenants are connected." +subtitle = "Review and manage child tenants linked under this tenant." [msg.admin.users] @@ -266,13 +266,13 @@ password_required = "Password Required" success = "User created successfully." [msg.admin.users.create.account] -subtitle = "Subtitle" +subtitle = "Fill in the account details required to create the user." [msg.admin.users.create.form] email_required = "Email Required" field_invalid = "Invalid {{label}} format." field_required = "{{label}} is required." -name_required = "Name Required" +name_required = "Name is required." password_auto_help = "Password Auto Help" password_manual_help = "Password Manual Help" role_help = "Role Help" @@ -290,26 +290,26 @@ password_generated = "A secure password has been generated." [msg.admin.users.detail.form] field_required = "Required." -name_required = "Name Required" +name_required = "Name is required." [msg.admin.users.detail.security] password_hint = "Password Hint" [msg.admin.users.list] -delete_confirm = "Delete Confirm" -empty = "Empty" -fetch_error = "Fetch Error" -subtitle = "Subtitle" +delete_confirm = "Are you sure you want to delete the selected user?" +empty = "No users match the current filters." +fetch_error = "Failed to load the user list." +subtitle = "Search and manage users registered in the current tenant." [msg.admin.users.list.columns] description = "Select columns to display in the table." no_custom = "No custom fields defined for this tenant." [msg.admin.users.list.registry] -count = "Count" +count = "{{count}} users loaded." [msg.common] -error = "Error" +error = "An error occurred." loading = "Loading..." no_description = "No Description." parsing = "Parsing data..." @@ -354,7 +354,7 @@ empty = "No consents found." load_error = "Error loading consents: {{error}}" loading = "Loading consents..." showing = "Showing {{from}} to {{to}} of {{total}} users" -subtitle = "Subtitle" +subtitle = "Review consent grants and users who have approved this application." revoke_confirm = "Are you sure you want to revoke this user's permissions? After revocation, the user must consent again on next login." [msg.dev.clients.details] @@ -369,15 +369,15 @@ rotate_confirm = "Rotate Confirm" rotate_error = "Rotate Error" save_error = "Save Error" secret_rotated = "Secret Rotated" -secret_unavailable = "SECRET_NOT_AVAILABLE" -subtitle = "Subtitle" +secret_unavailable = "The client secret is not available." +subtitle = "Inspect this application's credentials, endpoints, and security settings." [msg.dev.clients.details.redirect] -description = "Description" +description = "List the allowed URLs that users can be redirected to after authentication. Separate multiple values with commas." [msg.dev.clients.details.security] -footer = "Footer" -note = "Note" +footer = "When rotating a secret, confirm the admin session TTL, rate limits, and notification flow." +note = "Keep endpoints read-only and link secret copy or rotation actions to audit logs." [msg.dev.clients.general] load_error = "Error loading client: {{error}}" @@ -393,14 +393,14 @@ empty = "No IdP configurations found." [msg.dev.clients.general.identity] logo_help = "Logo Help" -subtitle = "Subtitle" +subtitle = "Manage the OIDC identity, branding, and basic metadata for this application." [msg.dev.clients.general.redirect] help = "Enter the redirect URIs. You can modify them in the Federation tab after creation." [msg.dev.clients.general.scopes] -empty = "Empty" -subtitle = "Subtitle" +empty = "No custom scopes have been added yet." +subtitle = "Define the scopes this application can request." [msg.dev.clients.general.security] private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers." @@ -422,7 +422,7 @@ profile = "Profile" [msg.dev.dashboard] [msg.dev.dashboard.hero] -body = "Body" +body = "Monitor RP readiness, consent activity, and operational status for the current developer workspace." title_emphasis = "Title Emphasis" title_prefix = "Title Prefix" title_suffix = "Title Suffix" @@ -756,16 +756,16 @@ name_placeholder = "Name Placeholder" section_name = "Section Name" section_scopes = "Section Scopes" submit = "Submit" -title = "Title" +title = "Create New API Key" [ui.admin.api_keys.create.success] copy_secret = "Copy Secret" go_list = "Go List" -title = "Title" +title = "API Key Created" [ui.admin.api_keys.list] add = "Add" -title = "Title" +title = "API Key Management" [ui.admin.api_keys.list.breadcrumb] list = "List" @@ -785,7 +785,7 @@ scopes = "SCOPES" export_csv = "Export CSV" load_more = "Load more" target = "Target · {{target}}" -title = "Title" +title = "Audit Logs" [ui.admin.audit.breadcrumb] logs = "Logs" @@ -831,7 +831,7 @@ import_csv = "Import Csv" [ui.admin.groups.create] description = "Adds a new organization unit such as a department or team." -title = "Title" +title = "Create Organization Unit" [ui.admin.groups.detail] breadcrumb_org = "Breadcrumb Org" @@ -843,7 +843,7 @@ permissions_subtitle = "Permissions Subtitle" permissions_title = "Permission Manage" [ui.admin.groups.form] -desc_label = "Description" +desc_label = "Description Label" desc_placeholder = "Desc Placeholder" name_label = "Group Name" name_placeholder = "Name Placeholder" @@ -899,7 +899,7 @@ title = "Admin playbook" add_tenant = "Tenant Add" api_key_management = "API Key Management" user_management = "User Management" -title = "Title" +title = "Quick Links" view_audit_logs = "View Audit Logs" [ui.admin.overview.summary] @@ -933,7 +933,7 @@ remove_title = "Remove Title" table_actions = "Table Actions" table_email = "Email" table_name = "Name" -title = "Title" +title = "Tenant Admins" [ui.admin.tenants.owners] add_button = "Add Owner" @@ -958,7 +958,7 @@ action = "Create" section = "Tenants" [ui.admin.tenants.create.form] -description = "Description" +description = "Tenant Description" domains_label = "Allowed Domains (Comma separated)" domains_placeholder = "example.com, example.kr" name = "Tenant name" @@ -970,7 +970,7 @@ status = "Status" type = "Type" [ui.admin.tenants.create.memo] -title = "Title" +title = "Policy Memo" [ui.admin.tenants.create.profile] title = "Tenant Profile" @@ -978,7 +978,7 @@ title = "Tenant Profile" [ui.admin.tenants.detail] breadcrumb_list = "Tenant List" header_subtitle = "Header Subtitle" -loading = "Loading" +loading = "Loading tenant details..." tab_federation = "Tab Federation" tab_organization = "Organization Manage" tab_permissions = "Permissions" @@ -1008,7 +1008,7 @@ status = "STATUS" allowed_domains = "Allowed Domains" allowed_domains_help = "Users with these email domains will be automatically assigned to this tenant." approve_button = "Approve Tenant" -description = "Description" +description = "Review and edit the tenant's basic profile information." name = "Tenant Name" slug = "Slug" status = "Status" @@ -1035,7 +1035,7 @@ type = "Type" type_boolean = "Boolean" type_date = "Date" type_number = "Number" -type_text = "Text" +type_text = "Text Value" validation_placeholder = "Regex Pattern (Optional)" type_datetime = "DateTime" type_float = "Float" @@ -1089,14 +1089,14 @@ submit = "User Create" title = "User Add" [ui.admin.users.create.account] -title = "Title" +title = "Account Information" [ui.admin.users.create.breadcrumb] new = "New" section = "Users" [ui.admin.users.create.custom_fields] -title = "Title" +title = "Tenant Custom Fields" [ui.admin.users.create.form] auto_password = "Auto Password" @@ -1119,7 +1119,7 @@ tenant = "Tenant" tenant_global = "Tenant Global" [ui.admin.users.create.password_generated] -title = "Title" +title = "Initial Password Generated" [ui.admin.users.detail] back = "Back" @@ -1158,10 +1158,10 @@ title = "Affiliation & Organization Info" [ui.admin.users.list] add = "User Add" bulk_import = "Bulk Import" -empty = "Empty" -fetch_error = "Fetch Error" +empty = "No users found." +fetch_error = "Failed to load the user list." search_placeholder = "Search Placeholder" -subtitle = "Subtitle" +subtitle = "Browse and manage registered users." title = "User Manage" [ui.admin.users.list.breadcrumb] @@ -1175,7 +1175,7 @@ title = "Column Settings" tenant = "Tenant Filter" [ui.admin.users.list.registry] -count = "Count" +count = "{{count}} users loaded." title = "User Registry" [ui.admin.users.list.table] @@ -1316,7 +1316,7 @@ role = "Roles & Permissions" [ui.dev.profile.basic] title = "User Info" -id = "User ID" +id = "Account ID" name = "Name" email = "Email" phone = "Phone Number" @@ -1414,6 +1414,7 @@ settings = "Settings" [ui.dev.clients.general] create = "Create Application" display_new = "Add Connected Application" +subtitle = "Manage application settings and security configuration." title_create = "Create Client" title_edit = "Client Settings" @@ -1423,7 +1424,7 @@ add_title = "Add Identity Provider" add_btn = "Add Provider" [ui.dev.clients.general.identity] -description = "Description" +description = "Application Description" description_placeholder = "Description Placeholder" logo = "App Logo URL" logo_placeholder = "https://example.com/logo.png" @@ -1443,7 +1444,7 @@ name_placeholder = "e.g. profile" title = "Scopes" [ui.dev.clients.general.scopes.table] -description = "Description" +description = "Scope Description" mandatory = "Mandatory" name = "Scope Name" delete = "Delete" @@ -1507,7 +1508,7 @@ subtitle = "Ship the RP controls" title = "Next actions" [ui.dev.dashboard.ops] -subtitle = "Subtitle" +subtitle = "Operational indicators for the current developer workspace." title = "Ops board" [ui.dev.dashboard.ops.card] diff --git a/locales/ko.toml b/locales/ko.toml index 3ee28279..58e5c558 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -163,9 +163,9 @@ forbidden = "이 작업을 수행할 권한이 없습니다." [msg.admin.audit] empty = "아직 수집된 감사 로그가 없습니다." -end = "End of audit feed" -load_error = "Error loading logs: {{error}}" -loading = "Loading audit logs..." +end = "감사 로그의 마지막입니다." +load_error = "감사 로그를 불러오지 못했습니다: {{error}}" +loading = "감사 로그를 불러오는 중..." subtitle = "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다." [msg.admin.header] @@ -221,9 +221,9 @@ subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합 deleted = "앱이 삭제되었습니다." delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." delete_error = "삭제 실패: {{error}}" -load_error = "Error loading clients: {{error}}" -loading = "Loading apps..." -showing = "Showing {{shown}} of {{total}} apps" +load_error = "앱 정보를 불러오지 못했습니다: {{error}}" +loading = "앱 정보를 불러오는 중..." +showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." [msg.dev.sidebar] notice = "개발자 전용 콘솔입니다." @@ -497,7 +497,7 @@ qr_scan = "QR 스캔" [ui.userfront.profile] department_empty = "소속 정보 없음" manage = "프로필 관리" -user_fallback = "User" +user_fallback = "사용자" [ui.userfront.qr] rescan = "다시 스캔" @@ -666,19 +666,19 @@ fetch_error = "사용자 목록 조회에 실패했습니다." subtitle = "시스템 사용자를 조회하고 관리합니다. (Local DB)" [msg.dev.clients.consents] -empty = "No consents found." -load_error = "Error loading consents: {{error}}" -loading = "Loading consents..." +empty = "등록된 동의 내역이 없습니다." +load_error = "동의 내역을 불러오지 못했습니다: {{error}}" +loading = "동의 내역을 불러오는 중..." revoke_confirm = "정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다." -showing = "Showing {{from}} to {{to}} of {{total}} users" +showing = "전체 {{total}}명 중 {{from}}번째부터 {{to}}번째 사용자를 표시합니다." subtitle = "OIDC Relying Party 사용자 권한을 검토·관리합니다." [msg.dev.clients.details] copy_client_id = "Client ID가 복사되었습니다." copy_client_secret = "Client Secret이 복사되었습니다." copy_endpoint = "{{label}}가 복사되었습니다." -load_error = "Error loading client: {{error}}" -loading = "Loading client..." +load_error = "앱 상세 정보를 불러오지 못했습니다: {{error}}" +loading = "앱 상세 정보를 불러오는 중..." missing_id = "Client ID가 필요합니다." redirect_saved = "Redirect URIs가 저장되었습니다." rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?" @@ -689,8 +689,8 @@ secret_unavailable = "SECRET_NOT_AVAILABLE" subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다." [msg.dev.clients.general] -load_error = "Error loading client: {{error}}" -loading = "Loading client..." +load_error = "앱 설정을 불러오지 못했습니다: {{error}}" +loading = "앱 설정을 불러오는 중..." save_error = "저장 실패: {{error}}" saved = "설정이 저장되었습니다." status_changed = "상태가 {{status}}로 변경되었습니다." @@ -1192,6 +1192,7 @@ title = "User Consent Grants" [ui.dev.clients.general] create = "앱 생성" display_new = "연동 앱 추가" +subtitle = "앱 설정과 보안 구성을 관리합니다." title_create = "연동 앱 생성" title_edit = "연동 앱 설정" @@ -1232,7 +1233,7 @@ profile = "기본 프로필 정보 접근" [ui.dev.clients.table] actions = "액션" application = "애플리케이션" -client_id = "Client ID" +client_id = "클라이언트 ID" created_at = "생성일" status = "상태" type = "유형" @@ -1535,7 +1536,7 @@ type = "타입" type_boolean = "Boolean" type_date = "Date" type_number = "Number" -type_text = "Text" +type_text = "텍스트" validation_placeholder = "정규표현식 (선택 사항)" type_datetime = "일시 (DateTime)" type_float = "실수 (Float)" @@ -1636,25 +1637,25 @@ current = "User Consent Grants" home = "Home" [ui.dev.clients.consents.filters] -advanced = "Advanced Filters" +advanced = "고급 필터" [ui.dev.clients.consents.stats] -active_grants = "Active Grants" -avg_scopes = "Avg. Scopes per User" -total_scopes = "Total Scopes Issued" +active_grants = "활성 동의 건수" +avg_scopes = "사용자당 평균 스코프" +total_scopes = "총 발급 스코프 수" [ui.dev.clients.consents.table] -action = "Action" -first_granted = "First Granted" -last_auth = "Last Authenticated" -scopes = "Granted Scopes" -status = "Status" -tenant = "Tenant" -user = "User" +action = "동작" +first_granted = "최초 동의 시각" +last_auth = "마지막 인증 시각" +scopes = "승인된 스코프" +status = "상태" +tenant = "테넌트" +user = "사용자" [ui.dev.clients.details.credentials] -client_id = "Client ID" -client_secret = "Client Secret" +client_id = "클라이언트 ID" +client_secret = "클라이언트 시크릿" title = "앱 자격 증명" [ui.dev.clients.details.endpoints] @@ -1663,7 +1664,7 @@ title = "OIDC 엔드포인트" [ui.dev.clients.details.redirect] callback_label = "인증 콜백 URL" -label = "Redirect URIs" +label = "리디렉션 URI" placeholder = "https://your-app.com/callback, http://localhost:3000/auth/callback" save = "Redirect URIs 저장" title = "리디렉션 URI 설정" @@ -1682,24 +1683,24 @@ consents = "동의 및 사용자" settings = "설정" [ui.dev.clients.general.identity] -description = "Description" +description = "설명" description_placeholder = "앱에 대한 간단한 설명을 입력하세요." -logo = "App Logo URL" +logo = "앱 로고 URL" logo_placeholder = "https://example.com/logo.png" -logo_preview = "Logo Preview" +logo_preview = "로고 미리보기" name = "앱 이름" -name_placeholder = "My Awesome Application" -title = "Application Identity" +name_placeholder = "예: 멋진 애플리케이션" +title = "애플리케이션 정보" [ui.dev.clients.general.redirect] -label = "Redirect URIs" +label = "리디렉션 URI" placeholder = "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)" [ui.dev.clients.general.scopes] add = "Scope 추가" description_placeholder = "권한에 대한 설명" name_placeholder = "e.g. profile" -title = "Scopes" +title = "스코프" [ui.dev.clients.general.security] private = "Server side App" @@ -1720,7 +1721,7 @@ label = "이메일 주소" title = "이메일 인증" [ui.dev.clients.general.scopes.table] -description = "Description" -mandatory = "Mandatory" -name = "Scope Name" -delete = "Delete" +description = "설명" +mandatory = "필수" +name = "스코프 이름" +delete = "삭제" diff --git a/locales/template.toml b/locales/template.toml index c8ecf672..0ff9d478 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -1192,6 +1192,7 @@ title = "" [ui.dev.clients.general] create = "" display_new = "" +subtitle = "" title_create = "" title_edit = "" diff --git a/tools/i18n-scanner/value-check.js b/tools/i18n-scanner/value-check.js new file mode 100644 index 00000000..9da4acab --- /dev/null +++ b/tools/i18n-scanner/value-check.js @@ -0,0 +1,365 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = process.cwd(); +const LOCALES_DIR = path.join(ROOT_DIR, 'locales'); +const TARGET_FILES = ['ko.toml', 'en.toml']; + +const PLACEHOLDER_VALUES = new Set([ + 'title', + 'subtitle', + 'description', + 'body', + 'note', + 'footer', + 'empty', + 'error', + 'count', + 'loading', + 'name required', + 'scope required', + 'scopes count', + 'scopes hint', + 'delete confirm', + 'fetch error', + 'create success', + 'create failed', + 'create error', + 'delete success', + 'delete error', + 'remove confirm', + 'remove success', + 'add success', + 'copy hint', + 'notice', + 'notice emphasis', + 'notice suffix', + 'idp policy', + 'idp body', + 'tenant body', + 'approve confirm', + 'approve success', + 'user id', + 'missing', + 'consent accept', + 'consent fetch', + 'consent reject', + 'linked app revoke', + 'login failed', + 'password reset complete', + 'password reset init', + 'load failed', + 'send code failed', + 'update failed', + 'verify code failed', + 'text', +]); + +const KO_UNTRANSLATED_VALUES = new Set([ + 'End of audit feed', + 'Error loading logs: {{error}}', + 'Loading audit logs...', + 'Error loading clients: {{error}}', + 'Loading apps...', + 'Showing {{shown}} of {{total}} apps', + 'No consents found.', + 'Error loading consents: {{error}}', + 'Loading consents...', + 'Showing {{from}} to {{to}} of {{total}} users', + 'Error loading client: {{error}}', + 'Loading client...', + 'Advanced Filters', + 'Active Grants', + 'Avg. Scopes per User', + 'Total Scopes Issued', + 'Action', + 'First Granted', + 'Last Authenticated', + 'Granted Scopes', + 'Status', + 'Tenant', + 'User', + 'Client ID', + 'Client Secret', + 'Redirect URIs', + 'Application Identity', + 'App Logo URL', + 'Logo Preview', + 'My Awesome Application', + 'Scopes', + 'Mandatory', + 'Scope Name', + 'Delete', + 'Description', +]); + +const SKIP_DIRS = new Set([ + '.git', + 'node_modules', + 'dist', + 'build', + '.dart_tool', + '.idea', + '.vscode', + 'coverage', + '.next', + '.cache', + 'tmp', + 'logs', +]); + +const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.dart']); + +const CODE_PATTERNS = [ + /\b(?:i18n\.)?t\s*\(\s*['"]([^'"]+)['"]/g, + /\btr\s*\(\s*['"]([^'"]+)['"]/g, + /['"]([^'"]+)['"]\s*\.tr\s*\(/g, +]; + +function readFileRequired(filePath) { + if (!fs.existsSync(filePath)) { + return { ok: false, error: `파일이 없습니다: ${filePath}` }; + } + return { ok: true, value: fs.readFileSync(filePath, 'utf8') }; +} + +function walkDir(dirPath, files) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) { + continue; + } + walkDir(path.join(dirPath, entry.name), files); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const ext = path.extname(entry.name).toLowerCase(); + if (!CODE_EXTENSIONS.has(ext)) { + continue; + } + + if (entry.name.includes('.test.') || entry.name.includes('.spec.')) { + continue; + } + + files.push(path.join(dirPath, entry.name)); + } +} + +function collectCodeKeys() { + const files = []; + walkDir(ROOT_DIR, files); + + const keys = new Set(); + for (const filePath of files) { + const content = fs.readFileSync(filePath, 'utf8'); + for (const pattern of CODE_PATTERNS) { + let match; + while ((match = pattern.exec(content)) !== null) { + if (match[1]) { + keys.add(match[1]); + } + } + } + } + + return keys; +} + +function stripInlineComment(value) { + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]; + const prev = i > 0 ? value[i - 1] : ''; + + if (ch === "'" && !inDouble && prev !== '\\') { + inSingle = !inSingle; + continue; + } + + if (ch === '"' && !inSingle && prev !== '\\') { + inDouble = !inDouble; + continue; + } + + if (ch === '#' && !inSingle && !inDouble) { + return value.slice(0, i).trimEnd(); + } + } + + return value.trimEnd(); +} + +function parseTomlStringEntries(filePath) { + const result = readFileRequired(filePath); + if (!result.ok) { + return { ok: false, error: result.error, entries: [] }; + } + + const entries = new Map(); + const lines = result.value.split(/\r?\n/); + let currentSection = []; + + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index]; + const line = rawLine.trim(); + + if (!line || line.startsWith('#')) { + continue; + } + + if (line.startsWith('[[') && line.endsWith(']]')) { + const sectionName = line.slice(2, -2).trim(); + currentSection = sectionName + ? sectionName.split('.').map((part) => part.trim()).filter(Boolean) + : []; + continue; + } + + if (line.startsWith('[') && line.endsWith(']')) { + const sectionName = line.slice(1, -1).trim(); + currentSection = sectionName + ? sectionName.split('.').map((part) => part.trim()).filter(Boolean) + : []; + continue; + } + + const eqIndex = rawLine.indexOf('='); + if (eqIndex === -1) { + continue; + } + + let key = rawLine.slice(0, eqIndex).trim(); + if (!key) { + continue; + } + + if (key.startsWith('"') && key.endsWith('"')) { + key = key.slice(1, -1); + } + + const rawValue = stripInlineComment(rawLine.slice(eqIndex + 1).trim()); + if ( + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")) + ) { + entries.set([...currentSection, key].join('.'), { + line: index + 1, + value: rawValue.slice(1, -1), + }); + } + } + + return { ok: true, entries }; +} + +function normalizeValue(value) { + return value.replace(/\{\{[^}]+\}\}/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase(); +} + +function formatFinding(fileName, line, key, value, reason) { + return `${fileName}:${line} ${key} = "${value}" (${reason})`; +} + +function main() { + const errors = []; + const findings = []; + const codeKeys = collectCodeKeys(); + const localeMaps = new Map(); + + for (const fileName of TARGET_FILES) { + const parsed = parseTomlStringEntries(path.join(LOCALES_DIR, fileName)); + if (!parsed.ok) { + errors.push(parsed.error); + continue; + } + localeMaps.set(fileName, parsed.entries); + } + + if (errors.length > 0) { + console.error('i18n 값 검증 실패'); + errors.forEach((error) => console.error(`- ${error}`)); + process.exit(1); + } + + const koEntries = localeMaps.get('ko.toml'); + const enEntries = localeMaps.get('en.toml'); + + for (const key of codeKeys) { + for (const fileName of TARGET_FILES) { + const entry = localeMaps.get(fileName).get(key); + if (!entry) { + continue; + } + + if (PLACEHOLDER_VALUES.has(normalizeValue(entry.value))) { + findings.push( + formatFinding(fileName, entry.line, key, entry.value, 'placeholder value'), + ); + } + } + + const koEntry = koEntries.get(key); + const enEntry = enEntries.get(key); + if (!koEntry || !enEntry) { + continue; + } + + if (koEntry.value === enEntry.value && KO_UNTRANSLATED_VALUES.has(koEntry.value)) { + findings.push( + formatFinding( + 'ko.toml', + koEntry.line, + key, + koEntry.value, + 'untranslated english value in Korean locale', + ), + ); + } + } + + const reportsDir = path.join(ROOT_DIR, 'reports'); + if (!fs.existsSync(reportsDir)) { + fs.mkdirSync(reportsDir, { recursive: true }); + } + + const generatedAt = new Date().toISOString(); + const summaryPath = path.join(reportsDir, 'i18n-value-report.txt'); + const jsonPath = path.join(reportsDir, 'i18n-value-report.json'); + + const summaryLines = [ + `generated_at: ${generatedAt}`, + `errors: ${errors.length}`, + `findings: ${findings.length}`, + ]; + if (findings.length > 0) { + summaryLines.push('details:'); + findings.forEach((finding) => summaryLines.push(`- ${finding}`)); + } + + fs.writeFileSync(summaryPath, `${summaryLines.join('\n')}\n`); + fs.writeFileSync( + jsonPath, + JSON.stringify({ generated_at: generatedAt, errors, findings }, null, 2), + ); + + if (findings.length > 0) { + console.error('i18n 값 품질 검증 실패'); + findings.forEach((finding) => console.error(`- ${finding}`)); + process.exit(1); + } + + console.log('✅ i18n 값 품질 검증 완료'); +} + +main();