diff --git a/.env.sample b/.env.sample index 1dce7295..9d4882d9 100644 --- a/.env.sample +++ b/.env.sample @@ -108,10 +108,6 @@ HYDRA_ADMIN_URL=http://hydra:4445 # Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다. HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc -# OIDC 클라이언트 callback (콤마 구분) -ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback -DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback - # Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택) # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. KRATOS_ALLOWED_RETURN_URLS_EXTRA=[] @@ -134,9 +130,11 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf CSRF_COOKIE_SECRET=localcsrf123 # AdminFront OIDC 설정 +ADMINFRONT_URL=http://localhost:5173 ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback # DevFront OIDC 설정 VITE_OIDC_CLIENT_ID=devfront VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc -DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback \ No newline at end of file +DEVFRONT_URL=http://localhost:5174 +DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index fab15b99..5ab178b0 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -87,6 +87,8 @@ jobs: ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }} ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }} USERFRONT_URL=${{ vars.USERFRONT_URL }} + ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }} + DEVFRONT_URL=${{ vars.DEVFRONT_URL }} BACKEND_URL=${{ vars.BACKEND_URL }} OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }} ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }} @@ -161,14 +163,11 @@ jobs: docker compose -f staging_pull_compose.yaml pull - # [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨. - docker compose -f staging_pull_compose.yaml down || true - # 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등) docker compose -f staging_pull_compose.yaml build --pull docker compose -f staging_pull_compose.yaml up -d --remove-orphans - docker compose -f staging_pull_compose.yaml up -d --force-recreate init-rp + docker compose -f staging_pull_compose.yaml up -d init-rp # 배포 후 상태 확인 (실패 시 로그 출력을 위함) sleep 10 diff --git a/Makefile b/Makefile index 0eea204a..8466a2b2 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,10 @@ 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 +CODE_CHECK_TEST_JOBS ?= 1 +PLAYWRIGHT_WORKERS ?= 1 +FLUTTER_TEST_CONCURRENCY ?= 1 + code-check: code-check-lint code-check-test-jobs @echo "code-check complete." @@ -124,7 +128,7 @@ code-check-lint: code-check-i18n code-check-front-lint code-check-go-lint code-c code-check-test-jobs: @echo "==> run CI-equivalent test jobs (parallel)" - @$(MAKE) --no-print-directory -j5 --output-sync=target \ + @$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \ code-check-backend-tests \ code-check-userfront-tests \ code-check-userfront-e2e-tests \ @@ -203,11 +207,11 @@ code-check-userfront-tests: rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \ fi; \ cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \ - cd "$$tmp_dir/userfront" && flutter test + cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY) code-check-adminfront-tests: @echo "==> adminfront tests" - ./scripts/run_adminfront_ci_tests.sh adminfront-tests + PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests code-check-devfront-tests: @echo "==> devfront tests" @@ -219,7 +223,7 @@ code-check-devfront-tests: (cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \ fi; \ if [ $$status -eq 0 ]; then \ - (cd devfront && npm test) || status=$$?; \ + (cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \ fi; \ [ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \ [ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \ @@ -267,7 +271,7 @@ code-check-userfront-e2e-tests: if [ $$status -eq 0 ]; then \ port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \ echo "==> userfront-e2e using PORT=$$port"; \ - (cd "$$tmp_dir/userfront-e2e" && PORT=$$port npm test) || status=$$?; \ + (cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \ fi; \ [ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \ [ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \ diff --git a/README.md b/README.md index 0378e249..113cd8a6 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,7 @@ KETO_WRITE_URL = "http://keto:4467" - **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다. - **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다. - **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`로 `userfront/lib/i18n_data.dart`에 사전 생성합니다. +- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다. - **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다. ## 🧪 Code Check CI diff --git a/adminfront/playwright.config.ts b/adminfront/playwright.config.ts index f4bfe8e1..2acbace2 100644 --- a/adminfront/playwright.config.ts +++ b/adminfront/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; +const configuredWorkers = process.env.PLAYWRIGHT_WORKERS + ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) + : undefined; + /** * Read environment variables from file. * https://github.com/motdotla/dotenv @@ -24,7 +28,7 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: configuredWorkers ?? (process.env.CI ? 1 : undefined), /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index 10ced54f..60679946 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -1101,6 +1101,9 @@ components: type: string phone: type: string + sessionAuthenticatedAt: + type: string + format: date-time department: type: string affiliationType: diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index b81181fe..f947413c 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -68,19 +68,20 @@ type SignupRequest struct { // User Profile Models type UserProfileResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` // 추가 - Department string `json:"department"` - AffiliationType string `json:"affiliationType"` - CompanyCode string `json:"companyCode,omitempty"` - TenantID *string `json:"tenantId,omitempty"` // 추가 - RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 - Metadata map[string]any `json:"metadata,omitempty"` - Tenant *Tenant `json:"tenant,omitempty"` - ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` // 추가 + SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"` + Department string `json:"department"` + AffiliationType string `json:"affiliationType"` + CompanyCode string `json:"companyCode,omitempty"` + TenantID *string `json:"tenantId,omitempty"` // 추가 + RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 + Metadata map[string]any `json:"metadata,omitempty"` + Tenant *Tenant `json:"tenant,omitempty"` + ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 } type UpdateUserRequest struct { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index b946b94b..5b6b98ae 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3388,13 +3388,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { name = clientID } - // ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback) - clientURL := strings.TrimSpace(client.ClientURI) - if clientURL == "" && len(client.RedirectURIs) > 0 { - if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { - clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) - } - } + clientURL := resolveLinkedRPURL( + client.ClientID, + client.ClientURI, + client.RedirectURIs, + ) lastAuth := time.Time{} if session.AuthenticatedAt != nil { @@ -3484,12 +3482,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { name = client.ClientID } - clientURL := strings.TrimSpace(client.ClientURI) - if clientURL == "" && len(client.RedirectURIs) > 0 { - if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil { - clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) - } - } + clientURL := resolveLinkedRPURL( + client.ClientID, + client.ClientURI, + client.RedirectURIs, + ) records[dc.ClientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ @@ -4889,37 +4886,43 @@ func extractLoginIDFromClaims(claims map[string]any) string { } func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { + identityID, traits, _, err := h.getKratosIdentityWithSession(sessionToken) + return identityID, traits, err +} + +func (h *AuthHandler) getKratosIdentityWithSession(sessionToken string) (string, map[string]interface{}, string, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { kratosURL = "http://kratos:4433" } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { - return "", nil, err + return "", nil, "", err } req.Header.Set("X-Session-Token", sessionToken) resp, err := http.DefaultClient.Do(req) if err != nil { - return "", nil, err + return "", nil, "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) + return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { - Identity struct { + AuthenticatedAt string `json:"authenticated_at"` + Identity struct { ID string `json:"id"` Traits map[string]interface{} `json:"traits"` } `json:"identity"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", nil, err + return "", nil, "", err } - return result.Identity.ID, result.Identity.Traits, nil + return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil } func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { @@ -4996,37 +4999,43 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) } func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) { + identityID, traits, _, err := h.getKratosIdentityWithCookieAndSession(cookie) + return identityID, traits, err +} + +func (h *AuthHandler) getKratosIdentityWithCookieAndSession(cookie string) (string, map[string]interface{}, string, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { kratosURL = "http://kratos:4433" } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { - return "", nil, err + return "", nil, "", err } req.Header.Set("Cookie", cookie) resp, err := http.DefaultClient.Do(req) if err != nil { - return "", nil, err + return "", nil, "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) + return "", nil, "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) } var result struct { - Identity struct { + AuthenticatedAt string `json:"authenticated_at"` + Identity struct { ID string `json:"id"` Traits map[string]interface{} `json:"traits"` } `json:"identity"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", nil, err + return "", nil, "", err } - return result.Identity.ID, result.Identity.Traits, nil + return result.Identity.ID, result.Identity.Traits, result.AuthenticatedAt, nil } func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) { @@ -5161,20 +5170,34 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s return profile } +func (h *AuthHandler) applySessionAuthenticatedAtFromWhoami(profile *domain.UserProfileResponse, authenticatedAt string) *domain.UserProfileResponse { + if profile == nil { + return nil + } + profile.SessionAuthenticatedAt = strings.TrimSpace(authenticatedAt) + return profile +} + func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { - identityID, traits, err := h.getKratosIdentity(sessionToken) + identityID, traits, authenticatedAt, err := h.getKratosIdentityWithSession(sessionToken) if err != nil { return nil, err } - return h.mapKratosIdentityToProfile(identityID, traits), nil + return h.applySessionAuthenticatedAtFromWhoami( + h.mapKratosIdentityToProfile(identityID, traits), + authenticatedAt, + ), nil } func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { - identityID, traits, err := h.getKratosIdentityWithCookie(cookie) + identityID, traits, authenticatedAt, err := h.getKratosIdentityWithCookieAndSession(cookie) if err != nil { return nil, err } - return h.mapKratosIdentityToProfile(identityID, traits), nil + return h.applySessionAuthenticatedAtFromWhoami( + h.mapKratosIdentityToProfile(identityID, traits), + authenticatedAt, + ), nil } // UpdateMe - Updates current user's profile with phone verification check @@ -5423,6 +5446,32 @@ func extractHydraClientLogo(metadata map[string]interface{}) string { return "" } +func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string) string { + switch strings.TrimSpace(clientID) { + case "adminfront": + if value := strings.TrimSpace(os.Getenv("ADMINFRONT_URL")); value != "" { + return value + } + case "devfront": + if value := strings.TrimSpace(os.Getenv("DEVFRONT_URL")); value != "" { + return value + } + } + + clientURL := strings.TrimSpace(clientURI) + if clientURL != "" { + return clientURL + } + + if len(redirectURIs) > 0 { + if parsed, err := url.Parse(redirectURIs[0]); err == nil { + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) + } + } + + return "" +} + func mergeScopes(current []string, next []string) []string { if len(next) == 0 { return current diff --git a/backend/internal/handler/auth_handler_session_profile_test.go b/backend/internal/handler/auth_handler_session_profile_test.go new file mode 100644 index 00000000..6b657b7d --- /dev/null +++ b/backend/internal/handler/auth_handler_session_profile_test.go @@ -0,0 +1,105 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/require" +) + +func TestGetMe_IncludesSessionAuthenticatedAtFromKratosSession(t *testing.T) { + const ( + token = "token-session" + identityID = "user-session" + sessionAuthenticated = "2026-03-23T15:30:00Z" + ) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "kratos.test" && + r.URL.Path == "/sessions/whoami" && + r.Method == http.MethodGet { + require.Equal(t, token, r.Header.Get("X-Session-Token")) + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "kratos-session-1", + "authenticated_at": sessionAuthenticated, + "identity": map[string]any{ + "id": identityID, + "traits": map[string]any{ + "email": "qa@example.com", + "name": "QA User", + "department": "Platform", + "affiliationType": "GENERAL", + }, + }, + }), nil + } + + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + setDefaultHTTPClientForTest(t, transport) + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + + h := &AuthHandler{} + app := fiber.New() + app.Get("/api/v1/user/me", h.GetMe) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var profile map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile)) + require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"]) +} + +func TestGetMe_IncludesSessionAuthenticatedAtForCookieSession(t *testing.T) { + const ( + cookieHeader = "ory_kratos_session=session-cookie" + identityID = "user-cookie" + sessionAuthenticated = "2026-03-24T01:20:00Z" + ) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "kratos.test" && + r.URL.Path == "/sessions/whoami" && + r.Method == http.MethodGet { + require.Equal(t, cookieHeader, r.Header.Get("Cookie")) + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "kratos-session-cookie", + "authenticated_at": sessionAuthenticated, + "identity": map[string]any{ + "id": identityID, + "traits": map[string]any{ + "email": "cookie@example.com", + "name": "Cookie User", + "department": "Platform", + "affiliationType": "GENERAL", + }, + }, + }), nil + } + + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + setDefaultHTTPClientForTest(t, transport) + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + + h := &AuthHandler{} + app := fiber.New() + app.Get("/api/v1/user/me", h.GetMe) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/me", nil) + req.Header.Set("Cookie", cookieHeader) + resp, err := app.Test(req, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var profile map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile)) + require.Equal(t, sessionAuthenticated, profile["sessionAuthenticatedAt"]) +} diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index ec3cb21a..61d08585 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -142,6 +142,10 @@ type clientUpsertRequest struct { Metadata *map[string]interface{} `json:"metadata"` } +var protectedSystemClientIDs = map[string]struct{}{ + "oathkeeper-introspect": {}, +} + func normalizeUserRole(role string) string { return domain.NormalizeRole(role) } @@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string { return strings.TrimSpace(profile.Role) } +func isProtectedSystemClientID(clientID string) bool { + _, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)] + return ok +} + +func isProtectedSystemClient(client domain.HydraClient) bool { + return isProtectedSystemClientID(client.ClientID) +} + func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if (!ok || profile == nil) && h.Auth != nil { @@ -557,6 +570,10 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { items := make([]clientSummary, 0, len(clients)) for _, client := range clients { + if isProtectedSystemClient(client) { + continue + } + summary := h.mapClientSummary(client) // 1. [Security] Filter out 'private' clients if user is not an AppManager @@ -604,6 +621,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*client) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + summary := h.mapClientSummary(*client) profile := h.getCurrentProfile(c) if profile == nil { @@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*current) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") + } + summary := h.mapClientSummary(*current) profile := h.getCurrentProfile(c) if profile == nil { @@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if clientID == "" { clientID = uuid.NewString() } + if isProtectedSystemClientID(clientID) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id") + } name := strings.TrimSpace(valueOr(req.Name, "")) if name == "" { @@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*current) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") + } + currentSummary := h.mapClientSummary(*current) profile := h.getCurrentProfile(c) if profile == nil { @@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*current) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") + } + summary := h.mapClientSummary(*current) profile := h.getCurrentProfile(c) if profile == nil { @@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*current) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") + } + summary := h.mapClientSummary(*current) profile := h.getCurrentProfile(c) if profile == nil { @@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error { var totalClients int64 if err == nil { for _, client := range clients { + if isProtectedSystemClient(client) { + continue + } if isSuperAdmin { totalClients++ continue diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index d42b99f5..a7c03619 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestListClients_ProtectedSystemClientHidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + {"client_id": "oathkeeper-introspect", "client_name": "Internal Client"}, + {"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Get("/api/v1/dev/clients", h.ListClients) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var res clientListResponse + _ = json.NewDecoder(resp.Body).Decode(&res) + assert.Len(t, res.Items, 1) + assert.Equal(t, "client-1", res.Items[0].ID) +} + func TestUpdateClientStatus_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -164,6 +202,38 @@ func TestUpdateClientStatus_Success(t *testing.T) { assert.Equal(t, "inactive", res.Client.Status) } +func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "oathkeeper-introspect", + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: new(devMockKetoService), + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) + + body, _ := json.Marshal(map[string]interface{}{"status": "inactive"}) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + func TestDeleteClient_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -204,6 +274,67 @@ func TestDeleteClient_Success(t *testing.T) { assert.Error(t, err) } +func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}}, + Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}}, + Keto: new(devMockKetoService), + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Delete("/api/v1/dev/clients/:id", h.DeleteClient) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestGetClient_ProtectedSystemClientHidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "oathkeeper-introspect", + "client_name": "Internal Client", + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: new(devMockKetoService), + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + func TestRotateClientSecret_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -254,6 +385,7 @@ func TestGetStats_Success(t *testing.T) { return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ {"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}}, + {"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}}, }), nil } diff --git a/compose.ory.yaml b/compose.ory.yaml index 3275a20d..e589acb2 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -211,6 +211,7 @@ services: hydra create oauth2-client \ --endpoint http://hydra:4445 \ --id adminfront \ + --name "AdminFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ @@ -220,6 +221,7 @@ services: hydra create oauth2-client \ --endpoint http://hydra:4445 \ --id devfront \ + --name "DevFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index 9606c1ce..b52394df 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -1,5 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; +const configuredWorkers = process.env.PLAYWRIGHT_WORKERS + ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) + : undefined; + /** * Read environment variables from file. * https://github.com/motdotla/dotenv @@ -20,7 +24,7 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: configuredWorkers ?? (process.env.CI ? 1 : undefined), /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index 01c6b86f..8172ea8f 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -275,31 +275,36 @@ services: tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra rm /tmp/hydra.tar.gz - hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true - hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true - hydra delete oauth2-client --endpoint http://hydra:4445 $${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true + # Function to create or update OAuth2 client (Idempotency) + upsert_client() { + ID=$1 + shift + if hydra get oauth2-client --endpoint http://hydra:4445 "$ID" >/dev/null 2>&1; then + echo "Updating existing client: $ID" + hydra update oauth2-client --endpoint http://hydra:4445 "$ID" "$@" + else + echo "Creating new client: $ID" + hydra create oauth2-client --endpoint http://hydra:4445 --id "$ID" "$@" + fi + } - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id adminfront \ + upsert_client "adminfront" \ + --name "AdminFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ --token-endpoint-auth-method none \ --redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}" - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id devfront \ + upsert_client "devfront" \ + --name "DevFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ --token-endpoint-auth-method none \ --redirect-uri "$${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}" - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \ + upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \ --secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \ --grant-type client_credentials \ --response-type token \ diff --git a/docs/i18n.md b/docs/i18n.md index 8a78bee2..26f0eaba 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -111,6 +111,14 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다. #### 5.2.2 관리 프로세스 (Template & CI) 1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다. 2. **`ko.toml`, `en.toml`**: 템플릿의 키를 바탕으로 실제 번역 값을 채워 넣습니다. +3. **UserFront 런타임 리소스 동기화**: + * UserFront는 런타임에 `userfront/assets/translations/*.toml`을 직접 읽습니다. + * 따라서 `locales/ko.toml`, `locales/en.toml`, `locales/template.toml`을 수정한 뒤에는 반드시 아래 동기화 스크립트를 실행해야 합니다. + * 실행 명령: + ```bash + ./scripts/sync_userfront_locales.sh + ``` + * 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다. 3. **CI 검증 (Verification)**: * **Level 1: 리소스 동기화 검사 (`template` vs `lang`)** * `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다. @@ -244,3 +252,14 @@ UserFront(`/error`)는 프로덕션에서 다음 규칙으로 에러를 표시 2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`). 3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동. 4. [ ] **UserFront**: TOML -> JSON 변환 스크립트 추가 및 `easy_localization` 연동. + +### 6.1 UserFront 번역 수정 체크포인트 + +UserFront 번역을 수정할 때는 아래 순서를 기본 절차로 사용합니다. + +1. `locales/*.toml` 수정 +2. `./scripts/sync_userfront_locales.sh` 실행 +3. UserFront 회귀 테스트 실행 + - 예: `cd userfront && flutter test test/english_locale_placeholder_test.dart` +4. 전체 키 정합성 점검 + - 예: `node tools/i18n-scanner/index.js` diff --git a/docs/trouble-shooting/issue-434-dashboard-session-start-time.md b/docs/trouble-shooting/issue-434-dashboard-session-start-time.md new file mode 100644 index 00000000..13067edf --- /dev/null +++ b/docs/trouble-shooting/issue-434-dashboard-session-start-time.md @@ -0,0 +1,45 @@ +# Issue #434 트러블슈팅 기록: 대시보드 세션 시작 시간이 `Unknown`으로 표시됨 + +## 기준 시점 +- 2026-03-24 KST +- 대상 화면: UserFront 대시보드 상단 세션 정보 칩 + +## 증상 +- 로그인 후 대시보드의 세션 시작 시간이 `Unknown` 또는 `알 수 없음`으로 표시됨 +- 특히 동일 브라우저의 cookie session 승격 경로에서 재현됨 + +## 원인 +1. 기존 대시보드는 저장된 로컬 토큰만 파싱해 `iat` 또는 `auth_time`을 읽었습니다. +2. cookie mode에서는 `AuthTokenStore.setCookieMode()`가 로컬 토큰을 제거하고 cookie 플래그만 유지합니다. +3. 그 결과 대시보드는 파싱할 JWT가 없어 항상 fallback 문구로 떨어졌습니다. + +## 수정 방향 +1. Backend `/api/v1/user/me` 응답에 Kratos `sessions/whoami`의 `authenticated_at` 값을 `sessionAuthenticatedAt`으로 포함합니다. +2. UserFront 대시보드는 세션 시각 계산 시 다음 우선순위를 사용합니다. + - JWT의 `iat` 또는 `auth_time` + - profile의 `sessionAuthenticatedAt` +3. 두 값이 모두 없을 때만 `ui.userfront.session.unknown` fallback을 사용합니다. + +## 반영 파일 +- `backend/internal/domain/auth_models.go` +- `backend/internal/handler/auth_handler.go` +- `backend/docs/openapi.yaml` +- `userfront/lib/features/profile/data/models/user_profile_model.dart` +- `userfront/lib/features/dashboard/domain/session_time_resolver.dart` +- `userfront/lib/features/dashboard/presentation/dashboard_screen.dart` + +## 회귀 테스트 +- Backend + - `backend/internal/handler/auth_handler_session_profile_test.go` +- UserFront + - `userfront/test/dashboard_session_time_resolver_test.dart` + - `userfront/test/dashboard_screen_smoke_test.dart` + +## 검증 명령 +- `GOCACHE=/tmp/go-build go test ./internal/handler -run 'TestGetMe_IncludesSessionAuthenticatedAt' -count=1` +- `flutter test test/dashboard_session_time_resolver_test.dart` +- `flutter test test/dashboard_screen_smoke_test.dart` + +## 남은 참고사항 +- Hydra introspection fallback만 사용되는 토큰 경로에서는 `sessionAuthenticatedAt`이 비어 있을 수 있습니다. +- 이 경우에도 JWT claim이 없으면 기존 fallback 문구를 유지합니다. diff --git a/locales/en.toml b/locales/en.toml index c63177e1..4148e964 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -726,38 +726,72 @@ consent_audit = "Consent Audit" dev_scope = "Dev Scope" hydra_health = "Hydra Health" +[msg.dev.sidebar] +notice = "Developer Console" +notice_detail = "Register and manage client applications." + +[msg.info] +saved_success = "Saved successfully." + + +[msg.userfront] +greeting = "Hello, {{name}}." + +[msg.userfront.audit] +date = "Date: {{value}}" +device = "Device: {{value}}" +end = "No more items to show." +ip = "IP address: {{value}}" +load_more_error = "Could not load more history." +result = "Result: {{value}}" +session_id = "Session ID: {{value}}" +status = "Status: pending" + +[msg.userfront.dashboard] +approved_device = "Approved device: {{device}}" +approved_ip = "Approved IP: {{ip}}" +audit_empty = "No recent sign-in activity." +audit_load_error = "Could not load sign-in history." +render_error = "Dashboard render error: {{error}}" +auth_method = "Auth method: {{method}}" +client_id = "Client ID: {{id}}" +client_id_missing = "No client ID available." +current_status = "Current status: {{status}}" +last_auth = "Last signed in: {{value}}" +link_missing = "This app does not have a launch URL configured." +link_open_error = "Could not open the app link." +session_id_copied = "Session ID copied." + [msg.userfront.dashboard.activities] -empty = "Empty" -empty_detail = "Empty Detail" -error = "Error" +empty = "No linked apps yet." +empty_detail = "Linked apps and their latest activity will appear here." +error = "Could not load linked apps." [msg.userfront.dashboard.approved_session] -copy_click = "Copy Click" -copy_tap = "Copy Tap" -none = "None" +copy_click = "{{label}}: {{id}}\\\\nClick to copy." +copy_tap = "{{label}}: {{id}}\\\\nTap to copy." +none = "No {{label}}" [msg.userfront.dashboard.revoke] -confirm = "Confirm" -error = "Error" -success = "Success" +confirm = "Disconnect {{app}}?\\\\nYou will need to grant access again the next time you sign in." +error = "Could not disconnect the app: {{error}}" +success = "{{app}} has been disconnected." [msg.userfront.dashboard.scopes] -empty = "Empty" +empty = "No scopes were requested." [msg.userfront.dashboard.timeline] -load_error = "Load Error" +load_error = "Could not load sign-in history." -[msg.userfront.error.whitelist] -"$normalizedCode" = "{{error}}" -settings_disabled = "Account settings are currently unavailable." -invalid_session = "Your session has expired. Please sign in again." -verification_required = "Additional verification is required. Please follow the instructions." -recovery_expired = "The recovery link has expired. Please request a new one." -recovery_invalid = "The recovery link is invalid." -rate_limited = "Too many requests. Please try again later." -not_found = "The requested page could not be found." -bad_request = "Please check your input." -password_or_email_mismatch = "Email or password does not match." +[msg.userfront.error] +detail_contact = "If the problem continues, please contact your administrator." +detail_generic = "Something went wrong." +detail_request = "We had trouble processing your request." +id = "Error ID: {{id}}" +title = "An error occurred during authentication." +title_generic = "An error occurred." +title_with_code = "Error: {{code}}" +type = "Error type: {{type}}" [msg.userfront.error.ory] "$normalizedCode" = "{{error}}" @@ -775,31 +809,80 @@ temporarily_unavailable = "The authentication server is temporarily unavailable. unauthorized_client = "The client is not authorized for this request." unsupported_response_type = "The response type is not supported." +[msg.userfront.error.whitelist] +"$normalizedCode" = "{{error}}" +bad_request = "Please check your input." +invalid_session = "Your session has expired. Please sign in again." +not_found = "The requested page could not be found." +password_or_email_mismatch = "Email or password does not match." +rate_limited = "Too many requests. Please try again later." +recovery_expired = "The recovery link has expired. Please request a new one." +recovery_invalid = "The recovery link is invalid." +settings_disabled = "Account settings are currently unavailable." +verification_required = "Additional verification is required. Please follow the instructions." + +[msg.userfront.forgot] +description = "Enter the email address or phone number linked to your account and we will send you a password reset link." +dry_send = "Dry-send mode: no email or SMS was actually sent." +error = "Failed to send the reset link: {{error}}" +input_required = "Enter your email address or phone number." +sent = "A password reset link has been sent. Check your email or SMS." + +[msg.userfront.login] +cookie_check_failed = "Could not verify your sign-in state: {{error}}" +dry_send = "Dry-send mode: no email or SMS was actually sent." +link_failed = "Could not complete link sign-in: {{error}}" +link_send_failed = "Failed to send the sign-in link: {{error}}" +link_sent_email = "We sent a sign-in link to your email address." +link_sent_phone = "We sent a sign-in link to your phone number." +link_timeout = "Time expired." +no_account = "New to Baron?" +oidc_failed = "OIDC sign-in failed. Please try again." +qr_expired = "Time expired." +qr_init_failed = "Failed to initialize QR sign-in: {{error}}" +qr_login_required = "You need to be signed in to approve a QR sign-in." +token_missing = "Could not find the sign-in token." +verification_failed = "Failed to approve the sign-in request: {{error}}" + [msg.userfront.login.link] -helper = "Sending you a login link" -missing_login_id = "Missing Login Id" -missing_phone = "Missing Phone" -resend_wait = "Resend Wait" -short_code_help = "Short Code Help" +approved = "Sign-in approved. You will be redirected to the sign-in page shortly." +helper = "We will send a sign-in link using the information you enter." +missing_login_id = "Enter your email address or phone number." +missing_phone = "Enter your phone number." +resend_wait = "You can resend in {{time}}." +short_code_help = "You can also sign in with the last 2 letters and 6 digits from the link you received." [msg.userfront.login.password] -failed = "Failed" -missing_credentials = "Missing Credentials" +failed = "Sign-in failed: {{error}}" +missing_credentials = "Enter both your email or phone number and your password." [msg.userfront.login.qr] -load_failed = "Load Failed" -scan_hint = "Scan Hint" +load_failed = "Could not load the QR code." +scan_hint = "Scan it with the mobile app." [msg.userfront.login.short_code] -invalid = "Invalid" +invalid = "Enter the 2 letters and 6 digits from your code." [msg.userfront.login.unregistered] -body = "Body" +body = "We could not find an account for that information.\\\\nPlease sign up before continuing." [msg.userfront.login.verification] -approved = "Approved" -approved_local = "Approved Local" -success = "Success" +approved = "Approved. Complete sign-in in the original window." +approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly." +success = "Sign-in approval completed." + +[msg.userfront.login_success] +subtitle = "You have signed in successfully." + +[msg.userfront.consent] +accept_error = "Failed to process consent: {{error}}" +client_id = "Client ID: {{id}}" +client_unknown = "Unknown application" +description = "The service below is requesting access to your account information.\nPlease choose whether to continue." +load_error = "Failed to load consent information: {{error}}" +missing_redirect = "Consent was processed, but the redirect URL was missing." +redirect_notice = "After consent, you will be redirected automatically." +scope_count = "Total {{count}}" [msg.userfront.consent.cancel] confirm = "If you cancel consent, you will not be able to use this service. Do you want to cancel?" @@ -812,92 +895,154 @@ openid = "OpenID authentication information (signin session check)" phone = "Phone number (identity verification and notifications)" profile = "Basic profile information (name, user identifier)" +[msg.userfront.profile] +department_missing = "No department information" +department_required = "Enter your department." +email_missing = "No email address" +greeting = "Hello, {{name}}." +load_failed = "Could not load your profile." +name_missing = "No name provided" +name_required = "Enter your name." +phone_required = "Enter your phone number." +phone_verify_required = "Phone verification is required." +update_failed = "Failed to update your profile: {{error}}" +update_success = "Your profile has been updated." + [msg.userfront.profile.password] -change_failed = "Change Failed" -changed = "Changed" -current_required = "Current Required" -mismatch = "Mismatch" -new_required = "New Required" -subtitle = "Subtitle" +change_failed = "Failed to change your password: {{error}}" +changed = "Your password has been changed." +current_required = "Enter your current password." +mismatch = "The new passwords do not match." +new_required = "Enter a new password." +subtitle = "Verify your current password before setting a new one." [msg.userfront.profile.phone] -code_sent = "Code Sent" -send_failed = "Send Failed" -verified = "Verified" -verify_failed = "Verify Failed" -verify_notice = "Verify Notice" +code_sent = "A verification code has been sent." +send_failed = "Failed to send the code: {{error}}" +verified = "Phone number verified." +verify_failed = "Verification failed: {{error}}" +verify_notice = "SMS verification is required to change your phone number." [msg.userfront.profile.section] -basic = "Basic" -organization = "Organization" -security = "Security" +basic = "Manage your basic account information." +organization = "Your organization and affiliation details." +security = "Keep your password secure." + +[msg.userfront.qr] +approve_error = "QR approval failed: {{error}}" +approve_success = "QR approval complete. Continue on your desktop." +camera_error = "Camera error: {{error}}" +permission_error = "Could not request camera access. Check your browser or OS settings." +permission_required = "Camera access is required." + +[msg.userfront.reset] +invalid_body = "This password reset link is invalid or has expired. Please request a new one." +invalid_link = "This reset link is invalid. Missing loginId or token." +invalid_title = "Invalid reset link" +policy_loading = "Loading the password policy..." +success = "Your password has been changed successfully. Please sign in again." [msg.userfront.reset.error] empty_password = "Please enter Password." -generic = "Generic" -lowercase = "Lowercase" -min_length = "Min Length" -min_types = "Min Types" -mismatch = "Mismatch" -number = "Number" -symbol = "Symbol" -uppercase = "Uppercase" +generic = "Failed to change your password: {{error}}" +lowercase = "Include at least one lowercase letter." +min_length = "Use at least {{count}} characters." +min_types = "Use at least {{count}} character types: uppercase, lowercase, number, or symbol." +mismatch = "The passwords do not match." +number = "Include at least one number." +symbol = "Include at least one symbol." +uppercase = "Include at least one uppercase letter." [msg.userfront.reset.policy] -lowercase = "Lowercase" -min_length = "Min Length" -min_types = "Min Types" -number = "Number" -symbol = "Symbol" -uppercase = "Uppercase" +lowercase = "At least one lowercase letter" +min_length = "At least {{count}} characters" +min_types = "At least {{count}} character types" +number = "At least one number" +symbol = "At least one symbol" +uppercase = "At least one uppercase letter" + +[msg.userfront.sections] +apps_subtitle = "Your linked apps and their latest sign-in status." +audit_subtitle = "Recent access history for Baron sign-in." + +[msg.userfront.settings] +disabled = "Account settings are currently unavailable." + +[msg.userfront.signup] +failed = "Failed" +privacy_full = "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n개인정보 수집 및 이용 동의\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n바론서비스 개인정보처리방침\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1조 (목적)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n바론컨설턴트(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"회사\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\")는 바론서비스(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\")를 이용하는 고객(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"이용자\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2조 (개인정보의 처리목적)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 제품소개서 다운로드: 설명자료 전달\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 보안가이드 제공: 안내자료 전달\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 기술지원 문의: 서비스 사용 지원\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3조 (개인정보의 처리 및 보유 기간)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 홍보, 상담, 계약용 개인정보: 2년\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4조 (개인정보의 제3자 제공)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 이용 목적: 개인정보 침해 민원 처리\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5조 (개인정보 처리 위탁)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 서면: 회사 주소로 서면 제출\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 전자우편: 회사 이메일로 요청\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 모사전송(FAX): 회사 FAX로 요청\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7조 (처리하는 개인정보의 항목)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 다음의 개인정보 항목을 처리합니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 수집 항목:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 선택 항목: 회사전화번호, 문의사항\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 수집 방법:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 홈페이지, 전화, 이메일을 통해 수집\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8조 (개인정보의 파기)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하여 보존합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n③ 개인정보 파기의 절차 및 방법은 다음과 같습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 파기 절차: 회사는 파기 사유가 발생한 개인정보를 선정하고, 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 파기 방법: 전자적 파일 형태로 기록된 개인정보는 복구할 수 없도록 기술적 방법을 사용해 삭제하며, 종이 문서에 기록된 개인정보는 분쇄기로 분쇄하거나 소각하여 파기합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9조 (개인정보의 안전성 확보 조치)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취합니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 관리적 조치: 내부관리계획 수립·시행, 정기적 직원 교육\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 기술적 조치: 개인정보처리시스템 접근 권한 관리, 접근통제시스템 설치, 고유식별정보 암호화, 보안 프로그램 설치\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 물리적 조치: 전산실 및 자료보관실 접근 통제\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10조 (개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 쿠키(Cookie)를 사용하지 않습니다. 쿠키는 이용자의 이용 정보를 저장하고 수시로 불러오는 작은 파일로, 바론서비스에서는 쿠키를 사용하지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11조 (개인정보 보호책임자)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 개인정보 처리에 관한 업무를 총괄하여 책임지고, 개인정보 처리와 관련된 정보주체의 불만처리 및 피해구제를 위해 개인정보 보호책임자를 지정하고 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n개인정보 보호책임자:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 성명: 염승호\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 직책: 수석연구원\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 연락처: 02-2141-7448\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 팩스번호: 02-2141-7599\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 이메일: b23008@baroncs.co.kr\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제12조 (개인정보 열람청구)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n정보주체는 「개인정보 보호법」 제35조에 따른 개인정보 열람 청구를 아래 부서에 할 수 있습니다. 회사는 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n개인정보 열람청구 접수·처리 부서:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 부서명: 총괄기획실\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 담당자: 권혁진\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 연락처: 02-2141-7465\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 팩스번호: 02-2141-7599\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 이메일: baroncs@baroncs.co.kr\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제13조 (권익침해 구제방법)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n정보주체는 개인정보 침해로 인한 구제를 위해 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보해신고센터 등에 분쟁 해결이나 상담을 신청할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 개인정보분쟁조정위원회: (국번없이) 1833-6972 (www.kopico.go.kr)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 개인정보침해신고센터: (국번없이) 118 (privacy.kisa.or.kr)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 대검찰청: (국번없이) 1301 (www.spo.go.kr)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 경찰청: (국번없이) 182 (www.police.go.kr)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제14조 (개인정보 처리방침의 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 개인정보처리방침은 법령, 정책 또는 보안 기술의 변경에 따라 내용의 추가, 삭제 및 수정이 있을 시, 개정 최소 7일 전에 홈페이지를 통해 사전 공지합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n부칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1조 (시행일자)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이 개인정보처리방침은 2024년 10월 1일부터 시행됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2조 (개정 및 고지의 의무)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 개인정보처리방침을 변경하는 경우, 변경사항을 시행일자 7일 전부터 서비스 내 공지사항 페이지를 통해 고지할 것입니다. 다만, 이용자의 권리나 의무에 중대한 변경이 발생하는 경우에는 시행일자 30일 전부터 고지합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3조 (유효성)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 개인정보처리방침의 일부 조항이 법적 또는 기타 사유로 인해 무효화되거나 시행할 수 없는 경우, 나머지 조항들은 계속해서 유효합니다. 무효화된 조항은 관련 법령에 부합하는 방식으로 수정되어 효력을 지속합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4조 (변경 통지의 방법)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 개인정보처리방침의 변경 시, 다음의 방법으로 이용자에게 고지합니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 서비스 초기화면 또는 팝업 공지\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 이메일 발송\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 회사 홈페이지 공지사항\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5조 (비회원의 개인정보 보호)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 비회원의 개인정보도 회원과 동일한 수준으로 보호합니다. 비회원이 개인정보 제공을 거부할 경우 일부 서비스 이용에 제한이 있을 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6조 (14세 미만 아동의 개인정보 보호)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 14세 미만 아동의 개인정보를 수집하지 않습니다. 만일 14세 미만 아동의 개인정보가 수집된 경우, 법정 대리인의 동의를 받아야 하며, 법정 대리인의 동의 없이 수집된 경우 이를 지체 없이 파기합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7조 (개인정보의 국외 이전)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8조 (기타)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n" +tos_full = "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n바론 소프트웨어 이용약관\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1장 총칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1조 (목적)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이 약관은 바론컨설턴트(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"회사\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2조 (용어의 정의)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3조 (약관의 효력 및 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4조 (약관 외 준칙)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2장 서비스 이용계약\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5조 (이용계약의 성립)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6조 (이용계약의 유보와 거절)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7조 (계약사항의 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3장 개인정보 보호\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8조 (개인정보 보호의 원칙)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9조 (개인정보처리방침 준수)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10조 (14세 미만 아동의 개인정보 보호)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4장 서비스 제공 및 이용\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11조 (서비스 제공)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제12조 (서비스의 변경 및 중단)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5장 정보 제공 및 광고\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제13조 (정보 제공 및 광고)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6장 게시물 관리\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제14조 (게시물의 관리)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제15조 (게시물의 저작권)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7장 계약 해지 및 이용 제한\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제16조 (계약 해지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제17조 (이용 제한)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8장 손해 배상 및 면책 조항\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제18조 (손해 배상)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제19조 (면책 조항)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9장 유료 서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n20조 (유료 서비스의 이용)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제21조(환불 정책)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제22조 (유료 서비스의 중지 및 해지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10장 양도 금지\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제23조 (양도 금지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11장 관할 법원\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제24조 (분쟁 해결)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제25조 (관할 법원)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n부칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관은 2024년 10월 1일부터 시행됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n" [msg.userfront.signup.agreement] -title = "Agreement Title" +all_hint = "Agree to both required documents to continue to the next step." +description = "Review the service terms and privacy collection notice, then agree to continue." +privacy_summary = "Review what personal data is collected, why it is used, and how it is retained." +progress = "{{count}} of {{total}} required agreements completed" +tos_summary = "Review the service terms, usage conditions, and responsibilities." +title = "Please review and agree to the terms to continue." [msg.userfront.signup.auth] -affiliate_notice = "Affiliate Notice" -title = "Auth Title" +affiliate_notice = "If you are an affiliate employee, use your official company email address." +title = "Verify your email and phone number." [msg.userfront.signup.email] -code_mismatch = "Code Mismatch" -duplicate = "Duplicate" -invalid = "Invalid" -send_failed = "Send Failed" -verified = "Verified" -verify_failed = "Verify Failed" +code_mismatch = "The verification code does not match." +duplicate = "This email address is already registered." +invalid = "Enter a valid email address." +send_failed = "Failed to send the email: {{error}}" +verified = "Email verified." +verify_failed = "Email verification failed: {{error}}" [msg.userfront.signup.password] -length_required = "Length Required" -lowercase_required = "Lowercase Required" -mismatch = "Mismatch" -number_required = "Number Required" -symbol_required = "Symbol Required" -title = "Password Title" -uppercase_required = "Uppercase Required" +length_required = "Your password must be at least 12 characters long." +lowercase_required = "Include at least one lowercase letter." +mismatch = "The passwords do not match." +number_required = "Include at least one number." +symbol_required = "Include at least one symbol." +title = "Create a secure password to finish signing up." +uppercase_required = "Include at least one uppercase letter." + +[msg.userfront.signup.password.rule] +lowercase = "Lowercase letter" +min_length = "At least {{count}} characters" +min_types = "At least {{count}} character types" +number = "Number" +symbol = "Symbol" +uppercase = "Uppercase letter" [msg.userfront.signup.phone] -code_mismatch = "Code Mismatch" -send_failed = "Send Failed" -verified = "Verified" -verify_failed = "Verify Failed" +code_mismatch = "The verification code does not match." +send_failed = "Failed to send the SMS: {{error}}" +verified = "Phone number verified." +verify_failed = "Phone verification failed: {{error}}" [msg.userfront.signup.policy] -loading = "Loading" -lowercase = "Lowercase" -min_length = "Min Length" -min_types = "Min Types" +loading = "Loading the password policy..." +lowercase = "Lowercase letter" +min_length = "At least {{count}} characters" +min_types = "At least {{count}} character types" number = "Number" -summary = "Summary" +summary = "Security policy: {{rules}}" symbol = "Symbol" -uppercase = "Uppercase" +uppercase = "Uppercase letter" [msg.userfront.signup.profile] -affiliate_hint = "Affiliate Hint" -title = "Profile Title" +affiliate_hint = "This will be selected automatically when you use an affiliate email." +title = "Tell us about your affiliation." [msg.userfront.signup.success] -body = "Body" -title = "Title" +body = "Your account has been created successfully." +title = "Sign-up complete" + + +[ui] + +[ui.admin] +brand = "Brand" +dev_role_switcher = "🛠 DEV Role Switcher" +title = "Admin Control" + +[ui.admin.api_keys] [ui.admin.api_keys.create] name_label = "Name Label" @@ -1258,6 +1403,30 @@ notes = "Setup notes" subtitle = "Devfront baseline" title = "Stack readiness" +[ui.dev.header] +plane = "Dev Plane" +subtitle = "Manage your applications" + +[ui.dev.session] +active = "Checking expiration..." +unknown = "Unknown" +expired = "Session expired" +expiring = "Expiring soon: {{minutes}}m {{seconds}}s left" +remaining = "Expires in: {{minutes}}m {{seconds}}s" +refresh = "Refresh session expiry" +refreshing = "Refreshing session expiry..." + + +[ui.userfront] +app_title = "Baron SW Portal" + +[ui.userfront.app_label] +admin_console = "Admin Console" +baron = "Baron" +dev_console = "Dev Console" + +[ui.userfront.audit] + [ui.userfront.audit.table] app = "App" auth_method = "Auth Method" @@ -1269,16 +1438,24 @@ result = "Result" session_id = "Session ID" status = "Status" +[ui.userfront.auth_method] +ory = "Ory" +session = "Session" + +[ui.userfront.dashboard] +last_auth_label = "Last sign-in" +status_history = "Activity history" + [ui.userfront.dashboard.activity] linked = "Linked" [ui.userfront.dashboard.approved_session] default = "Default" -userfront = "Userfront" +userfront = "Approved UserFront session ID" [ui.userfront.dashboard.revoke] -confirm_button = "Confirm Button" -title = "Title" +confirm_button = "Disconnect" +title = "Disconnect app" [ui.userfront.dashboard.scopes] title = "Permission (Scopes)" @@ -1286,17 +1463,41 @@ title = "Permission (Scopes)" [ui.userfront.dashboard.status] revoked = "Revoked" +[ui.userfront.device] +android = "Mobile(Android)" +ios = "Mobile(iOS)" +linux = "Desktop(Linux)" +macos = "Desktop(macOS)" +windows = "Desktop(Windows)" + +[ui.userfront.error] +go_home = "Go Home" +go_login = "Go Login" + +[ui.userfront.forgot] +heading = "Forgot your password?" +input_label = "Email address or phone number" +submit = "Send reset link" +title = "Reset password" + +[ui.userfront.login] +forgot_password = "Forgot Password" +signup = "Sign up" + [ui.userfront.login.action] -submit = "Submit" +submit = "Sign in" [ui.userfront.login.field] -login_id = "Emain or Phone Number" +login_id = "Email address or phone number" password = "Password" [ui.userfront.login.link] -code_only = "Code Only" -resend_with_time = "Resend With Time" +action_label = "Go to sign-in" +code_only = "Get code only ({{time}})" +page_title = "Link sign-in" +resend_with_time = "Resend ({{time}})" send = "Send" +title = "Link sign-in complete" [ui.userfront.login.qr] expired = "Expired" @@ -1304,29 +1505,50 @@ refresh = "Refresh" remaining = "Remaining: {{time}}" [ui.userfront.login.short_code] -digits = "Digits" -expire_time = "Expire Time" -prefix = "Prefix" -submit = "Submit" +digits = "6 digits" +expire_time = "Expires in {{time}}" +prefix = "2 letters" +submit = "Sign in with code" [ui.userfront.login.tabs] -link = "Link/Code" +link = "Sign-in link" password = "Password" qr = "QR Code" [ui.userfront.login.unregistered] -action = "Action" -title = "Title" +action = "Create an account" +title = "Account not found" [ui.userfront.login.verification] -action_label = "Confirm" -page_title = "Page Title" -title = "Title" +action_label = "Done" +page_title = "Sign-in approval" +title = "Approval complete" + +[ui.userfront.login_success] +later = "Do this later (go to dashboard)" +qr = "Use QR approval" +title = "Sign-in complete" + +[ui.userfront.consent] +accept = "Agree and continue" +requested_scopes = "Requested permissions" +title = "Permission request" [ui.userfront.consent.cancel] confirm_button = "Yes, cancel" title = "Cancel consent" +[ui.userfront.nav] +dashboard = "Dashboard" +logout = "Logout" +profile = "Profile" +qr_scan = "QR Scan" + +[ui.userfront.profile] +department_empty = "Department Empty" +manage = "Manage profile" +user_fallback = "User" + [ui.userfront.profile.field] affiliation = "Affiliation" company_code = "Company Code" @@ -1341,11 +1563,11 @@ confirm = "Confirm" current = "Current" forgot = "Forgot" new = "New" -title = "Title" +title = "Change password" [ui.userfront.profile.phone] -code_hint = "Code Hint" -request_code = "Request Code" +code_hint = "6-digit code" +request_code = "Send code" title = "Phone number" [ui.userfront.profile.section] @@ -1353,372 +1575,67 @@ basic = "Basic" organization = "Organization" security = "Security" +[ui.userfront.qr] +request_permission = "Allow camera access" +rescan = "Rescan" +result_failure = "Approval failed" +result_success = "Approval complete" +title = "Scan QR Code" + +[ui.userfront.reset] +confirm_password = "Confirm Password" +new_password = "New Password" +submit = "Submit" +subtitle = "Set a new password" +title = "Create a new password" + +[ui.userfront.sections] +apps = "Apps" +audit = "Audit" + +[ui.userfront.session] +active = "Active session" +unknown = "Unknown" + +[ui.userfront.signup] +complete = "Finish sign-up" +next_step = "Next" +title = "Sign up" + [ui.userfront.signup.agreement] -all = "All" -privacy_title = "Privacy Title" -tos_title = "Tos Title" +all = "Agree to all" +privacy_title = "Privacy Policy (Required)" +required = "Required" +tos_title = "Terms of Service (Required)" [ui.userfront.signup.auth] -code_label = "Code Label" -request_code = "Request Code" +code_label = "6-digit verification code" +request_code = "Send code" + +[ui.userfront.signup.auth.email] +label = "Email address" +title = "Email verification" [ui.userfront.signup.password] confirm_label = "Password Confirm" label = "Password" [ui.userfront.signup.phone] -label = "Label" -title = "Title" +label = "Phone number (no hyphens)" +title = "Phone verification" [ui.userfront.signup.profile] affiliation_type = "Affiliation Type" company = "Company" department = "Department" -department_optional = "Department Optional" +department_optional = "Department (optional)" name = "Name" [ui.userfront.signup.steps] -agreement = "Agreement" +agreement = "Terms" password = "Password" profile = "Profile" -verify = "Verify" +verify = "Verification" [ui.userfront.signup.success] -action = "Action" - -[msg.admin.api_keys.create.success] -copy_hint = "Copy Hint" -notice = "Notice" -notice_emphasis = "Notice Emphasis" -notice_suffix = "Notice Suffix" - -[msg.admin.api_keys.list.registry] -count = "Count" - -[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" - -[msg.admin.tenants.create.profile] -subtitle = "Subtitle" - -[msg.admin.users.create.account] -subtitle = "Subtitle" - -[msg.admin.users.create.form] -email_required = "Email Required" -field_invalid = "Invalid {{label}} format." -field_required = "{{label}} is required." -name_required = "Name Required" -password_auto_help = "Password Auto Help" -password_manual_help = "Password Manual Help" -role_help = "Role Help" - -[msg.admin.users.create.password_generated] -default = "Default" -with_email = "With Email" - -[msg.admin.users.detail.form] -field_required = "Required." -name_required = "Name Required" - -[msg.admin.users.detail.security] -password_hint = "Password Hint" - -[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" - -[msg.dev.clients.details.redirect] -description = "Description" - -[msg.dev.clients.details.security] -footer = "Footer" -note = "Note" - -[msg.dev.clients.general.identity] -logo_help = "Logo Help" -subtitle = "Subtitle" - -[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" - -[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." -pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory." -subtitle = "Select application type. Security level determines authentication method." - -[msg.userfront.signup.password.rule] -lowercase = "Lowercase" -min_length = "Min Length" -min_types = "Min Types" -number = "Number" -symbol = "Symbol" -uppercase = "Uppercase" - -[ui.admin.api_keys.create.success] -copy_secret = "Copy Secret" -go_list = "Go List" -title = "Title" - -[ui.admin.api_keys.list.breadcrumb] -list = "List" -section = "API Keys" - -[ui.admin.api_keys.list.registry] -title = "API Key Registry" - -[ui.admin.api_keys.list.table] -actions = "ACTIONS" -client_id = "CLIENT ID" -last_used = "LAST USED" -name = "NAME" -scopes = "SCOPES" - -[ui.admin.groups.members.table] -email = "Email" -name = "Name" -remove = "Remove" - -[ui.admin.tenants.create.breadcrumb] -action = "Create" -section = "Tenants" - -[ui.admin.tenants.create.form] -description = "Description" -domains_label = "Allowed Domains (Comma separated)" -domains_placeholder = "example.com, example.kr" -name = "Tenant name" -name_placeholder = "Enter tenant name" -parent = "Parent" -slug = "Slug" -slug_placeholder = "tenant-slug" -status = "Status" -type = "Type" - -[ui.admin.tenants.create.memo] -title = "Title" - -[ui.admin.tenants.create.profile] -title = "Tenant Profile" - -[ui.admin.tenants.members.table] -email = "EMAIL" -name = "NAME" -role = "ROLE" -status = "STATUS" - -[ui.admin.tenants.profile.form] -parent = "Parent Tenant (Optional)" -parent_help = "Select a parent tenant if this is a subsidiary or sub-organization." - -[ui.admin.tenants.schema.field] -admin_only = "Admin Only" -key = "Field Key (ID)" -key_placeholder = "e.g. employee_id" -label = "Display Label" -label_placeholder = "Label Placeholder" -required = "Required" -type = "Type" -type_boolean = "Boolean" -type_date = "Date" -type_number = "Number" -type_text = "Text" -validation_placeholder = "Regex Pattern (Optional)" -type_datetime = "DateTime" -type_float = "Float" -unsigned = "Unsigned" - -[ui.admin.tenants.sub.table] -action = "ACTION" -name = "NAME" -slug = "SLUG" -status = "STATUS" - -[ui.admin.users.create.account] -title = "Title" - -[ui.admin.users.create.breadcrumb] -new = "New" -section = "Users" - -[ui.admin.users.create.custom_fields] -title = "Title" - -[ui.admin.users.create.form] -auto_password = "Auto Password" -department = "Department" -department_placeholder = "Department Placeholder" -email = "Email" -email_placeholder = "user@example.com" -job_title = "Job Title" -job_title_placeholder = "e.g. Frontend Developer" -name = "Name" -name_placeholder = "Name Placeholder" -password = "Password" -password_placeholder = "********" -phone = "Phone number" -phone_placeholder = "010-1234-5678" -position = "Position" -position_placeholder = "e.g. Senior" -role = "Role" -tenant = "Tenant" -tenant_global = "Tenant Global" - -[ui.admin.users.create.password_generated] -title = "Title" - -[ui.admin.users.detail.breadcrumb] -section = "Users" - -[ui.admin.users.detail.custom_fields] -multi_title = "Per-tenant Profile Management" - -[ui.admin.users.detail.form] -department = "Department" -department_placeholder = "Department Placeholder" -name = "Name" -name_placeholder = "Name Placeholder" -phone = "Phone number" -phone_placeholder = "010-1234-5678" -role = "Role" -status = "Status" -tenant = "Representative Affiliated Tenant" -tenant_global = "Tenant Global" - -[ui.admin.users.detail.security] -password = "Password" -password_placeholder = "Password Placeholder" -title = "Security Settings" - -[ui.admin.users.detail.tenants_section] -additional = "Additional Affiliated/Manageable Tenants" -primary = "Representative Affiliated Tenant" -title = "Affiliation & Organization Info" - -[ui.admin.users.list.breadcrumb] -list = "List" -section = "Users" - -[ui.admin.users.list.columns] -title = "Column Settings" - -[ui.admin.users.list.filter] -tenant = "Tenant Filter" - -[ui.admin.users.list.registry] -count = "Count" -title = "User Registry" - -[ui.admin.users.list.table] -actions = "ACTIONS" -created = "CREATED" -name_email = "NAME / EMAIL" -role = "ROLE" -status = "STATUS" -tenant_dept = "TENANT / DEPT" - -[ui.dev.clients.consents.breadcrumb] -clients = "Clients" -current = "User Consent Grants" -home = "Home" - -[ui.dev.clients.consents.filters] -advanced = "Advanced Filters" - -[ui.dev.clients.consents.stats] -active_grants = "Active Grants" -avg_scopes = "Avg. Scopes per User" -total_scopes = "Total Scopes Issued" - -[ui.dev.clients.consents.table] -action = "Action" -first_granted = "First Granted" -last_auth = "Last Authenticated" -scopes = "Granted Scopes" -status = "Status" -tenant = "Tenant" -user = "User" - -[ui.dev.clients.details.credentials] -client_id = "Client ID" -client_secret = "Client Secret" -title = "Client Credentials" - -[ui.dev.clients.details.endpoints] -read_only = "Read Only" -title = "OIDC Endpoints" - -[ui.dev.clients.details.redirect] -callback_label = "Callback Label" -label = "Redirect URIs" -placeholder = "https://your-app.com/callback, http://localhost:3000/auth/callback" -save = "Save" -title = "Redirection Settings" - -[ui.dev.clients.details.secret] -hide = "Hide" -rotate = "Rotate" -show = "Show" - -[ui.dev.clients.details.security] -title = "Security Note" - -[ui.dev.clients.details.tab] -connection = "Federation" -consents = "Consent & Users" -settings = "Settings" - -[ui.dev.clients.general.identity] -description = "Description" -description_placeholder = "Description Placeholder" -logo = "App Logo URL" -logo_placeholder = "https://example.com/logo.png" -logo_preview = "Logo Preview" -name = "Name" -name_placeholder = "My Awesome Application" -title = "Application Identity" - -[ui.dev.clients.general.redirect] -label = "Redirect URIs" -placeholder = "Placeholder" - -[ui.dev.clients.general.scopes] -add = "Scope Add" -description_placeholder = "Description Placeholder" -name_placeholder = "e.g. profile" -title = "Scopes" - -[ui.dev.clients.general.security] -private = "Server Side App" -pkce = "PKCE" -title = "Security Settings" - -[ui.dev.dashboard.ops.card] -consent_revoked = "Consent Revoked" -hydra_status = "Hydra Status" -rp_requests = "Rp Requests" - -[ui.dev.dashboard.ops.tag] -consent = "Consent grants" -rp_status = "RP status" - -[ui.userfront.signup.auth.email] -label = "Label" -title = "Title" - -[ui.dev.clients.general.scopes.table] -description = "Description" -mandatory = "Mandatory" -name = "Scope Name" -delete = "Delete" +action = "Go to sign-in" diff --git a/locales/ko.toml b/locales/ko.toml index 768596cf..3ee28279 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -849,6 +849,11 @@ symbol = "특수문자 1개 이상" uppercase = "대문자 1개 이상" [msg.userfront.signup.agreement] +all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다." +description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요." +privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다." +progress = "필수 약관 {{total}}개 중 {{count}}개 동의 완료" +tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다." title = "서비스 이용을 위해\n약관에 동의해주세요" [msg.userfront.signup.auth] @@ -1352,6 +1357,7 @@ security = "보안" [ui.userfront.signup.agreement] all = "모두 동의합니다" privacy_title = "개인정보 수집 및 이용 동의 (필수)" +required = "필수" tos_title = "바론 소프트웨어 이용약관 (필수)" [ui.userfront.signup.auth] diff --git a/locales/template.toml b/locales/template.toml index b7cc2d6b..c8ecf672 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -849,6 +849,11 @@ symbol = "" uppercase = "" [msg.userfront.signup.agreement] +all_hint = "" +description = "" +privacy_summary = "" +progress = "" +tos_summary = "" title = "" [msg.userfront.signup.auth] @@ -1352,6 +1357,7 @@ security = "" [ui.userfront.signup.agreement] all = "" privacy_title = "" +required = "" tos_title = "" [ui.userfront.signup.auth] diff --git a/scripts/auth_config.sh b/scripts/auth_config.sh index 0a9f3bee..735320ed 100755 --- a/scripts/auth_config.sh +++ b/scripts/auth_config.sh @@ -17,8 +17,10 @@ USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}" OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}" HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}" KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}" -ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}" -DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}" +ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}" +DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}" +ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}" +DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}" KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}" declare -a WARNINGS=() @@ -382,12 +384,21 @@ run_validation() { validate_dotenv_line_safety "HYDRA_PUBLIC_URL" validate_dotenv_line_safety "KRATOS_BROWSER_URL" validate_dotenv_line_safety "KRATOS_UI_URL" + validate_dotenv_line_safety "ADMINFRONT_URL" + validate_dotenv_line_safety "DEVFRONT_URL" validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS" validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS" + if [[ -n "$ADMINFRONT_URL" ]]; then + validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL" + fi + if [[ -n "$DEVFRONT_URL" ]]; then + validate_urls "DEVFRONT_URL" "$DEVFRONT_URL" + fi + collect_values validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}" - validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}" + validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}" validate_gateway_mapping build_allowed_return_urls } diff --git a/userfront-e2e/playwright.config.ts b/userfront-e2e/playwright.config.ts index d7e33047..fcc38967 100644 --- a/userfront-e2e/playwright.config.ts +++ b/userfront-e2e/playwright.config.ts @@ -4,13 +4,16 @@ const port = Number.parseInt(process.env.PORT ?? '4173', 10); const defaultBaseUrl = `http://127.0.0.1:${port}`; const baseURL = process.env.BASE_URL ?? defaultBaseUrl; const reuseExistingServer = !process.env.CI; +const configuredWorkers = process.env.PLAYWRIGHT_WORKERS + ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) + : undefined; export default defineConfig({ testDir: './tests', fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: configuredWorkers ?? (process.env.CI ? 1 : undefined), reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'html', use: { baseURL, diff --git a/userfront-e2e/tests/profile-department.spec.ts b/userfront-e2e/tests/profile-department.spec.ts index 4d8b468d..e22db24d 100644 --- a/userfront-e2e/tests/profile-department.spec.ts +++ b/userfront-e2e/tests/profile-department.spec.ts @@ -47,6 +47,11 @@ async function blurDepartmentEditor(page: Page): Promise { await page.waitForTimeout(250); } +async function submitDepartmentEditor(page: Page): Promise { + await page.keyboard.press('Enter'); + await page.waitForTimeout(250); +} + async function mockProfileApis(page: Page, state: ProfileState): Promise { await page.route('**/api/v1/**', async (route: Route) => { const request = route.request(); @@ -155,7 +160,7 @@ test.describe('UserFront WASM profile department editing', () => { await page.unroute('**/api/v1/**'); }); - test('소속 수정 후 포커스 아웃하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({ + test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({ page, }) => { const state: ProfileState = { @@ -170,7 +175,7 @@ test.describe('UserFront WASM profile department editing', () => { await openDepartmentEditor(page); await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated'); - await blurDepartmentEditor(page); + await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); expect(state.putBodies[0]?.department).toBe('QA-Updated'); @@ -248,7 +253,7 @@ test.describe('UserFront WASM profile department editing', () => { expect(state.department).toBe('QA'); }); - test('소속을 수정한 뒤 새로고침 후 다시 수정해도 저장 요청이 누락되지 않는다', async ({ page }) => { + test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => { const state: ProfileState = { department: 'QA', getMeCount: 0, @@ -261,7 +266,7 @@ test.describe('UserFront WASM profile department editing', () => { await openDepartmentEditor(page); await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1'); - await blurDepartmentEditor(page); + await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); await page.reload(); @@ -270,7 +275,7 @@ test.describe('UserFront WASM profile department editing', () => { await openDepartmentEditor(page); await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2'); - await blurDepartmentEditor(page); + await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(2); expect(state.putBodies[0]?.department).toBe('QA-1'); diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index c49affda..538eeb5b 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -41,75 +41,63 @@ verify_code_failed = "Verify Code Failed" missing = "Missing" [msg.userfront] -greeting = "Greeting" +greeting = "Hello, {name}." [msg.userfront.audit] -date = "Date" -device = "Device" -end = "End" -ip = "Ip" -load_more_error = "Load More Error" -result = "Result" +date = "Date: {value}" +device = "Device: {value}" +end = "No more items to show." +ip = "IP address: {value}" +load_more_error = "Could not load more history." +result = "Result: {value}" session_id = "Session ID: {value}" -status = "Status" +status = "Status: pending" [msg.userfront.dashboard] -approved_device = "Approved Device" -approved_ip = "Approve IP: {ip}" -audit_empty = "Audit Empty" -audit_load_error = "Audit Load Error" +approved_device = "Approved device: {device}" +approved_ip = "Approved IP: {ip}" +audit_empty = "No recent sign-in activity." +audit_load_error = "Could not load sign-in history." render_error = "Dashboard render error: {error}" -auth_method = "Auth Method" +auth_method = "Auth method: {method}" client_id = "Client ID: {id}" -client_id_missing = "Client Id Missing" -current_status = "Current Status" -last_auth = "Last Auth" -link_missing = "Link Missing" -link_open_error = "Link Open Error" -session_id_copied = "Session Id Copied" +client_id_missing = "No client ID available." +current_status = "Current status: {status}" +last_auth = "Last signed in: {value}" +link_missing = "This app does not have a launch URL configured." +link_open_error = "Could not open the app link." +session_id_copied = "Session ID copied." [msg.userfront.dashboard.activities] -empty = "Empty" -empty_detail = "Empty Detail" -error = "Error" +empty = "No linked apps yet." +empty_detail = "Linked apps and their latest activity will appear here." +error = "Could not load linked apps." [msg.userfront.dashboard.approved_session] -copy_click = "Copy Click" -copy_tap = "Copy Tap" -none = "None" +copy_click = "{label}: {id}\\\\nClick to copy." +copy_tap = "{label}: {id}\\\\nTap to copy." +none = "No {label}" [msg.userfront.dashboard.revoke] -confirm = "Confirm" -error = "Error" -success = "Success" +confirm = "Disconnect {app}?\\\\nYou will need to grant access again the next time you sign in." +error = "Could not disconnect the app: {error}" +success = "{app} has been disconnected." [msg.userfront.dashboard.scopes] -empty = "Empty" +empty = "No scopes were requested." [msg.userfront.dashboard.timeline] -load_error = "Load Error" +load_error = "Could not load sign-in history." [msg.userfront.error] -detail_contact = "Please contact administrator." -detail_generic = "Detail Generic" -detail_request = "Detail Request" -id = "Id" -title = "Title" -title_generic = "Title Generic" -title_with_code = "Title With Code" -type = "Type" - -[msg.userfront.error.whitelist] -"$normalizedCode" = "{error}" -settings_disabled = "Account settings are currently unavailable." -invalid_session = "Your session has expired. Please sign in again." -verification_required = "Additional verification is required. Please follow the instructions." -recovery_expired = "The recovery link has expired. Please request a new one." -recovery_invalid = "The recovery link is invalid." -rate_limited = "Too many requests. Please try again later." -not_found = "The requested page could not be found." -bad_request = "Please check your input." -password_or_email_mismatch = "Email or password does not match." +detail_contact = "If the problem continues, please contact your administrator." +detail_generic = "Something went wrong." +detail_request = "We had trouble processing your request." +id = "Error ID: {id}" +title = "An error occurred during authentication." +title_generic = "An error occurred." +title_with_code = "Error: {code}" +type = "Error type: {type}" [msg.userfront.error.ory] "$normalizedCode" = "{error}" @@ -127,57 +115,70 @@ temporarily_unavailable = "The authentication server is temporarily unavailable. unauthorized_client = "The client is not authorized for this request." unsupported_response_type = "The response type is not supported." +[msg.userfront.error.whitelist] +"$normalizedCode" = "{error}" +bad_request = "Please check your input." +invalid_session = "Your session has expired. Please sign in again." +not_found = "The requested page could not be found." +password_or_email_mismatch = "Email or password does not match." +rate_limited = "Too many requests. Please try again later." +recovery_expired = "The recovery link has expired. Please request a new one." +recovery_invalid = "The recovery link is invalid." +settings_disabled = "Account settings are currently unavailable." +verification_required = "Additional verification is required. Please follow the instructions." + [msg.userfront.forgot] -description = "Description" -dry_send = "Dry Send" -error = "Error" -input_required = "Input Required" -sent = "Sent" +description = "Enter the email address or phone number linked to your account and we will send you a password reset link." +dry_send = "Dry-send mode: no email or SMS was actually sent." +error = "Failed to send the reset link: {error}" +input_required = "Enter your email address or phone number." +sent = "A password reset link has been sent. Check your email or SMS." [msg.userfront.login] -cookie_check_failed = "Cookie Check Failed" -dry_send = "Dry Send" -link_failed = "Link Failed" -link_send_failed = "Link Send Failed" -link_sent_email = "Link Sent Email" -link_sent_phone = "Link Sent Phone" +cookie_check_failed = "Could not verify your sign-in state: {error}" +dry_send = "Dry-send mode: no email or SMS was actually sent." +link_failed = "Could not complete link sign-in: {error}" +link_send_failed = "Failed to send the sign-in link: {error}" +link_sent_email = "We sent a sign-in link to your email address." +link_sent_phone = "We sent a sign-in link to your phone number." link_timeout = "Time expired." no_account = "New to Baron?" -oidc_failed = "OIDC Failed" +oidc_failed = "OIDC sign-in failed. Please try again." qr_expired = "Time expired." -qr_init_failed = "QR Init Failed" -qr_login_required = "QR Login Required" -token_missing = "Token Missing" -verification_failed = "Verification Failed" +qr_init_failed = "Failed to initialize QR sign-in: {error}" +qr_login_required = "You need to be signed in to approve a QR sign-in." +token_missing = "Could not find the sign-in token." +verification_failed = "Failed to approve the sign-in request: {error}" [msg.userfront.login.link] -helper = "Sending you a login link" -missing_login_id = "Missing Login Id" -missing_phone = "Missing Phone" -resend_wait = "Resend Wait" -short_code_help = "Short Code Help" +approved = "Sign-in approved. You will be redirected to the sign-in page shortly." +helper = "We will send a sign-in link using the information you enter." +missing_login_id = "Enter your email address or phone number." +missing_phone = "Enter your phone number." +resend_wait = "You can resend in {time}." +short_code_help = "You can also sign in with the last 2 letters and 6 digits from the link you received." [msg.userfront.login.password] -failed = "Failed" -missing_credentials = "Missing Credentials" +failed = "Sign-in failed: {error}" +missing_credentials = "Enter both your email or phone number and your password." [msg.userfront.login.qr] -load_failed = "Load Failed" -scan_hint = "Scan Hint" +load_failed = "Could not load the QR code." +scan_hint = "Scan it with the mobile app." [msg.userfront.login.short_code] -invalid = "Invalid" +invalid = "Enter the 2 letters and 6 digits from your code." [msg.userfront.login.unregistered] -body = "Body" +body = "We could not find an account for that information.\\\\nPlease sign up before continuing." [msg.userfront.login.verification] -approved = "Approved" -approved_local = "Approved Local" -success = "Success" +approved = "Approved. Complete sign-in in the original window." +approved_local = "Approved. This device is already signed in, and the remote window will be signed in shortly." +success = "Sign-in approval completed." [msg.userfront.login_success] -subtitle = "Subtitle" +subtitle = "You have signed in successfully." [msg.userfront.consent] accept_error = "Failed to process consent: {error}" @@ -201,136 +202,144 @@ phone = "Phone number (identity verification and notifications)" profile = "Basic profile information (name, user identifier)" [msg.userfront.profile] -department_missing = "Department Missing" -department_required = "Department Required" -email_missing = "Email Missing" -greeting = "Greeting" -load_failed = "Load Failed" -name_missing = "Name Missing" -name_required = "Name Required" -phone_required = "Phone Required" -phone_verify_required = "Phone Verify Required" -update_failed = "Update Failed" -update_success = "Update Success" +department_missing = "No department information" +department_required = "Enter your department." +email_missing = "No email address" +greeting = "Hello, {name}." +load_failed = "Could not load your profile." +name_missing = "No name provided" +name_required = "Enter your name." +phone_required = "Enter your phone number." +phone_verify_required = "Phone verification is required." +update_failed = "Failed to update your profile: {error}" +update_success = "Your profile has been updated." [msg.userfront.profile.password] -change_failed = "Change Failed" -changed = "Changed" -current_required = "Current Required" -mismatch = "Mismatch" -new_required = "New Required" -subtitle = "Subtitle" +change_failed = "Failed to change your password: {error}" +changed = "Your password has been changed." +current_required = "Enter your current password." +mismatch = "The new passwords do not match." +new_required = "Enter a new password." +subtitle = "Verify your current password before setting a new one." [msg.userfront.profile.phone] -code_sent = "Code Sent" -send_failed = "Send Failed" -verified = "Verified" -verify_failed = "Verify Failed" -verify_notice = "Verify Notice" +code_sent = "A verification code has been sent." +send_failed = "Failed to send the code: {error}" +verified = "Phone number verified." +verify_failed = "Verification failed: {error}" +verify_notice = "SMS verification is required to change your phone number." [msg.userfront.profile.section] -basic = "Basic" -organization = "Organization" -security = "Security" +basic = "Manage your basic account information." +organization = "Your organization and affiliation details." +security = "Keep your password secure." [msg.userfront.qr] -camera_error = "Camera Error" -permission_error = "Permission Error" -permission_required = "Permission Required" +approve_error = "QR approval failed: {error}" +approve_success = "QR approval complete. Continue on your desktop." +camera_error = "Camera error: {error}" +permission_error = "Could not request camera access. Check your browser or OS settings." +permission_required = "Camera access is required." [msg.userfront.reset] -invalid_body = "Invalid Body" -invalid_link = "Invalid Link" -invalid_title = "Invalid Title" -policy_loading = "Policy Loading" -success = "Success" +invalid_body = "This password reset link is invalid or has expired. Please request a new one." +invalid_link = "This reset link is invalid. Missing loginId or token." +invalid_title = "Invalid reset link" +policy_loading = "Loading the password policy..." +success = "Your password has been changed successfully. Please sign in again." [msg.userfront.reset.error] empty_password = "Please enter Password." -generic = "Generic" -lowercase = "Lowercase" -min_length = "Min Length" -min_types = "Min Types" -mismatch = "Mismatch" -number = "Number" -symbol = "Symbol" -uppercase = "Uppercase" +generic = "Failed to change your password: {error}" +lowercase = "Include at least one lowercase letter." +min_length = "Use at least {count} characters." +min_types = "Use at least {count} character types: uppercase, lowercase, number, or symbol." +mismatch = "The passwords do not match." +number = "Include at least one number." +symbol = "Include at least one symbol." +uppercase = "Include at least one uppercase letter." [msg.userfront.reset.policy] -lowercase = "Lowercase" -min_length = "Min Length" -min_types = "Min Types" -number = "Number" -symbol = "Symbol" -uppercase = "Uppercase" +lowercase = "At least one lowercase letter" +min_length = "At least {count} characters" +min_types = "At least {count} character types" +number = "At least one number" +symbol = "At least one symbol" +uppercase = "At least one uppercase letter" [msg.userfront.sections] -apps_subtitle = "Apps Subtitle" -audit_subtitle = "Audit Subtitle" +apps_subtitle = "Your linked apps and their latest sign-in status." +audit_subtitle = "Recent access history for Baron sign-in." [msg.userfront.settings] -disabled = "Disabled" +disabled = "Account settings are currently unavailable." [msg.userfront.signup] failed = "Failed" -privacy_full = "Privacy Full" -tos_full = "Tos Full" +privacy_full = "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n개인정보 수집 및 이용 동의\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n바론서비스 개인정보처리방침\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1조 (목적)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n바론컨설턴트(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"회사\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\")는 바론서비스(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\")를 이용하는 고객(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"이용자\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2조 (개인정보의 처리목적)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 제품소개서 다운로드: 설명자료 전달\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 보안가이드 제공: 안내자료 전달\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 기술지원 문의: 서비스 사용 지원\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3조 (개인정보의 처리 및 보유 기간)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 홍보, 상담, 계약용 개인정보: 2년\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4조 (개인정보의 제3자 제공)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 이용 목적: 개인정보 침해 민원 처리\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5조 (개인정보 처리 위탁)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 서면: 회사 주소로 서면 제출\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 전자우편: 회사 이메일로 요청\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 모사전송(FAX): 회사 FAX로 요청\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7조 (처리하는 개인정보의 항목)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 다음의 개인정보 항목을 처리합니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 수집 항목:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 선택 항목: 회사전화번호, 문의사항\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 수집 방법:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 홈페이지, 전화, 이메일을 통해 수집\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8조 (개인정보의 파기)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하여 보존합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n③ 개인정보 파기의 절차 및 방법은 다음과 같습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 파기 절차: 회사는 파기 사유가 발생한 개인정보를 선정하고, 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 파기 방법: 전자적 파일 형태로 기록된 개인정보는 복구할 수 없도록 기술적 방법을 사용해 삭제하며, 종이 문서에 기록된 개인정보는 분쇄기로 분쇄하거나 소각하여 파기합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9조 (개인정보의 안전성 확보 조치)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취합니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 관리적 조치: 내부관리계획 수립·시행, 정기적 직원 교육\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 기술적 조치: 개인정보처리시스템 접근 권한 관리, 접근통제시스템 설치, 고유식별정보 암호화, 보안 프로그램 설치\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 물리적 조치: 전산실 및 자료보관실 접근 통제\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10조 (개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 쿠키(Cookie)를 사용하지 않습니다. 쿠키는 이용자의 이용 정보를 저장하고 수시로 불러오는 작은 파일로, 바론서비스에서는 쿠키를 사용하지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11조 (개인정보 보호책임자)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 개인정보 처리에 관한 업무를 총괄하여 책임지고, 개인정보 처리와 관련된 정보주체의 불만처리 및 피해구제를 위해 개인정보 보호책임자를 지정하고 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n개인정보 보호책임자:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 성명: 염승호\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 직책: 수석연구원\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 연락처: 02-2141-7448\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 팩스번호: 02-2141-7599\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 이메일: b23008@baroncs.co.kr\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제12조 (개인정보 열람청구)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n정보주체는 「개인정보 보호법」 제35조에 따른 개인정보 열람 청구를 아래 부서에 할 수 있습니다. 회사는 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n개인정보 열람청구 접수·처리 부서:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 부서명: 총괄기획실\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 담당자: 권혁진\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 연락처: 02-2141-7465\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 팩스번호: 02-2141-7599\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 이메일: baroncs@baroncs.co.kr\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제13조 (권익침해 구제방법)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n정보주체는 개인정보 침해로 인한 구제를 위해 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보해신고센터 등에 분쟁 해결이나 상담을 신청할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 개인정보분쟁조정위원회: (국번없이) 1833-6972 (www.kopico.go.kr)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 개인정보침해신고센터: (국번없이) 118 (privacy.kisa.or.kr)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 대검찰청: (국번없이) 1301 (www.spo.go.kr)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 경찰청: (국번없이) 182 (www.police.go.kr)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제14조 (개인정보 처리방침의 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 개인정보처리방침은 법령, 정책 또는 보안 기술의 변경에 따라 내용의 추가, 삭제 및 수정이 있을 시, 개정 최소 7일 전에 홈페이지를 통해 사전 공지합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n부칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1조 (시행일자)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이 개인정보처리방침은 2024년 10월 1일부터 시행됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2조 (개정 및 고지의 의무)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 개인정보처리방침을 변경하는 경우, 변경사항을 시행일자 7일 전부터 서비스 내 공지사항 페이지를 통해 고지할 것입니다. 다만, 이용자의 권리나 의무에 중대한 변경이 발생하는 경우에는 시행일자 30일 전부터 고지합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3조 (유효성)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 개인정보처리방침의 일부 조항이 법적 또는 기타 사유로 인해 무효화되거나 시행할 수 없는 경우, 나머지 조항들은 계속해서 유효합니다. 무효화된 조항은 관련 법령에 부합하는 방식으로 수정되어 효력을 지속합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4조 (변경 통지의 방법)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 개인정보처리방침의 변경 시, 다음의 방법으로 이용자에게 고지합니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 서비스 초기화면 또는 팝업 공지\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 이메일 발송\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- 회사 홈페이지 공지사항\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5조 (비회원의 개인정보 보호)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 비회원의 개인정보도 회원과 동일한 수준으로 보호합니다. 비회원이 개인정보 제공을 거부할 경우 일부 서비스 이용에 제한이 있을 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6조 (14세 미만 아동의 개인정보 보호)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 14세 미만 아동의 개인정보를 수집하지 않습니다. 만일 14세 미만 아동의 개인정보가 수집된 경우, 법정 대리인의 동의를 받아야 하며, 법정 대리인의 동의 없이 수집된 경우 이를 지체 없이 파기합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7조 (개인정보의 국외 이전)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8조 (기타)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n" +tos_full = "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n바론 소프트웨어 이용약관\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1장 총칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제1조 (목적)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이 약관은 바론컨설턴트(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"회사\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2조 (용어의 정의)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3조 (약관의 효력 및 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4조 (약관 외 준칙)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제2장 서비스 이용계약\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5조 (이용계약의 성립)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6조 (이용계약의 유보와 거절)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7조 (계약사항의 변경)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제3장 개인정보 보호\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8조 (개인정보 보호의 원칙)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9조 (개인정보처리방침 준수)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10조 (14세 미만 아동의 개인정보 보호)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제4장 서비스 제공 및 이용\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11조 (서비스 제공)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제12조 (서비스의 변경 및 중단)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제5장 정보 제공 및 광고\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제13조 (정보 제공 및 광고)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제6장 게시물 관리\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제14조 (게시물의 관리)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제15조 (게시물의 저작권)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제7장 계약 해지 및 이용 제한\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제16조 (계약 해지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제17조 (이용 제한)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제8장 손해 배상 및 면책 조항\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제18조 (손해 배상)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제19조 (면책 조항)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제9장 유료 서비스\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n20조 (유료 서비스의 이용)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제21조(환불 정책)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제22조 (유료 서비스의 중지 및 해지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제10장 양도 금지\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제23조 (양도 금지)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제11장 관할 법원\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제24조 (분쟁 해결)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n제25조 (관할 법원)\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n부칙\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n본 약관은 2024년 10월 1일부터 시행됩니다.\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\n" [msg.userfront.signup.agreement] -title = "Agreement Title" +all_hint = "Agree to both required documents to continue to the next step." +description = "Review the service terms and privacy collection notice, then agree to continue." +privacy_summary = "Review what personal data is collected, why it is used, and how it is retained." +progress = "{count} of {total} required agreements completed" +tos_summary = "Review the service terms, usage conditions, and responsibilities." +title = "Please review and agree to the terms to continue." [msg.userfront.signup.auth] -affiliate_notice = "Affiliate Notice" -title = "Auth Title" +affiliate_notice = "If you are an affiliate employee, use your official company email address." +title = "Verify your email and phone number." [msg.userfront.signup.email] -code_mismatch = "Code Mismatch" -duplicate = "Duplicate" -invalid = "Invalid" -send_failed = "Send Failed" -verified = "Verified" -verify_failed = "Verify Failed" +code_mismatch = "The verification code does not match." +duplicate = "This email address is already registered." +invalid = "Enter a valid email address." +send_failed = "Failed to send the email: {error}" +verified = "Email verified." +verify_failed = "Email verification failed: {error}" [msg.userfront.signup.password] -length_required = "Length Required" -lowercase_required = "Lowercase Required" -mismatch = "Mismatch" -number_required = "Number Required" -symbol_required = "Symbol Required" -title = "Password Title" -uppercase_required = "Uppercase Required" +length_required = "Your password must be at least 12 characters long." +lowercase_required = "Include at least one lowercase letter." +mismatch = "The passwords do not match." +number_required = "Include at least one number." +symbol_required = "Include at least one symbol." +title = "Create a secure password to finish signing up." +uppercase_required = "Include at least one uppercase letter." [msg.userfront.signup.password.rule] -lowercase = "Lowercase" -min_length = "Min Length" -min_types = "Min Types" +lowercase = "Lowercase letter" +min_length = "At least {count} characters" +min_types = "At least {count} character types" number = "Number" symbol = "Symbol" -uppercase = "Uppercase" +uppercase = "Uppercase letter" [msg.userfront.signup.phone] -code_mismatch = "Code Mismatch" -send_failed = "Send Failed" -verified = "Verified" -verify_failed = "Verify Failed" +code_mismatch = "The verification code does not match." +send_failed = "Failed to send the SMS: {error}" +verified = "Phone number verified." +verify_failed = "Phone verification failed: {error}" [msg.userfront.signup.policy] -loading = "Loading" -lowercase = "Lowercase" -min_length = "Min Length" -min_types = "Min Types" +loading = "Loading the password policy..." +lowercase = "Lowercase letter" +min_length = "At least {count} characters" +min_types = "At least {count} character types" number = "Number" -summary = "Summary" +summary = "Security policy: {rules}" symbol = "Symbol" -uppercase = "Uppercase" +uppercase = "Uppercase letter" [msg.userfront.signup.profile] -affiliate_hint = "Affiliate Hint" -title = "Profile Title" +affiliate_hint = "This will be selected automatically when you use an affiliate email." +title = "Tell us about your affiliation." [msg.userfront.signup.success] -body = "Body" -title = "Title" +body = "Your account has been created successfully." +title = "Sign-up complete" + [ui.common] add = "Add" @@ -426,19 +435,19 @@ ory = "Ory" session = "Session" [ui.userfront.dashboard] -last_auth_label = "Last Auth Label" -status_history = "Status History" +last_auth_label = "Last sign-in" +status_history = "Activity history" [ui.userfront.dashboard.activity] linked = "Linked" [ui.userfront.dashboard.approved_session] default = "Default" -userfront = "Userfront" +userfront = "Approved UserFront session ID" [ui.userfront.dashboard.revoke] -confirm_button = "Confirm Button" -title = "Title" +confirm_button = "Disconnect" +title = "Disconnect app" [ui.userfront.dashboard.scopes] title = "Permission (Scopes)" @@ -458,26 +467,29 @@ go_home = "Go Home" go_login = "Go Login" [ui.userfront.forgot] -heading = "Heading" -input_label = "Input Label" -submit = "Submit" -title = "Title" +heading = "Forgot your password?" +input_label = "Email address or phone number" +submit = "Send reset link" +title = "Reset password" [ui.userfront.login] forgot_password = "Forgot Password" -signup = "Signup" +signup = "Sign up" [ui.userfront.login.action] -submit = "Submit" +submit = "Sign in" [ui.userfront.login.field] -login_id = "Emain or Phone Number" +login_id = "Email address or phone number" password = "Password" [ui.userfront.login.link] -code_only = "Code Only" -resend_with_time = "Resend With Time" +action_label = "Go to sign-in" +code_only = "Get code only ({time})" +page_title = "Link sign-in" +resend_with_time = "Resend ({time})" send = "Send" +title = "Link sign-in complete" [ui.userfront.login.qr] expired = "Expired" @@ -485,29 +497,29 @@ refresh = "Refresh" remaining = "Remaining: {time}" [ui.userfront.login.short_code] -digits = "Digits" -expire_time = "Expire Time" -prefix = "Prefix" -submit = "Submit" +digits = "6 digits" +expire_time = "Expires in {time}" +prefix = "2 letters" +submit = "Sign in with code" [ui.userfront.login.tabs] -link = "Link/Code" +link = "Sign-in link" password = "Password" qr = "QR Code" [ui.userfront.login.unregistered] -action = "Action" -title = "Title" +action = "Create an account" +title = "Account not found" [ui.userfront.login.verification] -action_label = "Confirm" -page_title = "Page Title" -title = "Title" +action_label = "Done" +page_title = "Sign-in approval" +title = "Approval complete" [ui.userfront.login_success] -later = "Later" -qr = "QR" -title = "Title" +later = "Do this later (go to dashboard)" +qr = "Use QR approval" +title = "Sign-in complete" [ui.userfront.consent] accept = "Agree and continue" @@ -526,7 +538,7 @@ qr_scan = "QR Scan" [ui.userfront.profile] department_empty = "Department Empty" -manage = "Manage" +manage = "Manage profile" user_fallback = "User" [ui.userfront.profile.field] @@ -543,11 +555,11 @@ confirm = "Confirm" current = "Current" forgot = "Forgot" new = "New" -title = "Title" +title = "Change password" [ui.userfront.profile.phone] -code_hint = "Code Hint" -request_code = "Request Code" +code_hint = "6-digit code" +request_code = "Send code" title = "Phone number" [ui.userfront.profile.section] @@ -556,67 +568,66 @@ organization = "Organization" security = "Security" [ui.userfront.qr] -camera_error = "Camera Error" -permission_error = "Permission Error" -permission_required = "Permission Required" +request_permission = "Allow camera access" rescan = "Rescan" -result_success = "Result Success" +result_failure = "Approval failed" +result_success = "Approval complete" title = "Scan QR Code" [ui.userfront.reset] confirm_password = "Confirm Password" new_password = "New Password" submit = "Submit" -subtitle = "Subtitle" -title = "Title" +subtitle = "Set a new password" +title = "Create a new password" [ui.userfront.sections] apps = "Apps" audit = "Audit" [ui.userfront.session] -active = "Active" +active = "Active session" unknown = "Unknown" [ui.userfront.signup] -complete = "Complete" -next_step = "Next Step" -title = "Title" +complete = "Finish sign-up" +next_step = "Next" +title = "Sign up" [ui.userfront.signup.agreement] -all = "All" -privacy_title = "Privacy Title" -tos_title = "Tos Title" +all = "Agree to all" +privacy_title = "Privacy Policy (Required)" +required = "Required" +tos_title = "Terms of Service (Required)" [ui.userfront.signup.auth] -code_label = "Code Label" -request_code = "Request Code" +code_label = "6-digit verification code" +request_code = "Send code" [ui.userfront.signup.auth.email] -label = "Label" -title = "Title" +label = "Email address" +title = "Email verification" [ui.userfront.signup.password] confirm_label = "Password Confirm" label = "Password" [ui.userfront.signup.phone] -label = "Label" -title = "Title" +label = "Phone number (no hyphens)" +title = "Phone verification" [ui.userfront.signup.profile] affiliation_type = "Affiliation Type" company = "Company" department = "Department" -department_optional = "Department Optional" +department_optional = "Department (optional)" name = "Name" [ui.userfront.signup.steps] -agreement = "Agreement" +agreement = "Terms" password = "Password" profile = "Profile" -verify = "Verify" +verify = "Verification" [ui.userfront.signup.success] -action = "Action" - +action = "Go to sign-in" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index ea883439..18d2b303 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -277,6 +277,11 @@ privacy_full = "개인정보 수집 및 이용 동의 전문..." tos_full = "서비스 이용약관 전문..." [msg.userfront.signup.agreement] +all_hint = "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다." +description = "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요." +privacy_summary = "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다." +progress = "필수 약관 {total}개 중 {count}개 동의 완료" +tos_summary = "서비스 이용 조건과 책임 범위를 확인할 수 있습니다." title = "서비스 이용을 위해\n약관에 동의해주세요" [msg.userfront.signup.auth] @@ -583,6 +588,7 @@ title = "회원가입" [ui.userfront.signup.agreement] all = "모두 동의합니다" privacy_title = "개인정보 수집 및 이용 동의 (필수)" +required = "필수" tos_title = "바론 소프트웨어 이용약관 (필수)" [ui.userfront.signup.auth] @@ -616,4 +622,3 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" - diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index c59a8780..44c85800 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -277,6 +277,11 @@ privacy_full = "" tos_full = "" [msg.userfront.signup.agreement] +all_hint = "" +description = "" +privacy_summary = "" +progress = "" +tos_summary = "" title = "" [msg.userfront.signup.auth] @@ -583,6 +588,7 @@ title = "" [ui.userfront.signup.agreement] all = "" privacy_title = "" +required = "" tos_title = "" [ui.userfront.signup.auth] @@ -616,4 +622,3 @@ verify = "" [ui.userfront.signup.success] action = "" - diff --git a/userfront/lib/core/ui/toast_service.dart b/userfront/lib/core/ui/toast_service.dart new file mode 100644 index 00000000..560f6e94 --- /dev/null +++ b/userfront/lib/core/ui/toast_service.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +enum ToastType { success, error, info } + +class _ToastItem { + const _ToastItem({ + required this.id, + required this.message, + required this.type, + }); + + final String id; + final String message; + final ToastType type; +} + +class ToastService { + static const Duration _displayDuration = Duration(milliseconds: 3000); + static final ValueNotifier> _toasts = + ValueNotifier>(<_ToastItem>[]); + + static void success(String message) { + show(message, type: ToastType.success); + } + + static void error(String message) { + show(message, type: ToastType.error); + } + + static void info(String message) { + show(message, type: ToastType.info); + } + + static void show(String message, {ToastType type = ToastType.success}) { + final trimmed = message.trim(); + if (trimmed.isEmpty) { + return; + } + + final item = _ToastItem( + id: '${DateTime.now().microsecondsSinceEpoch}-${_toasts.value.length}', + message: trimmed, + type: type, + ); + + _toasts.value = [..._toasts.value, item]; + + unawaited( + Future.delayed(_displayDuration, () { + _remove(item.id); + }), + ); + } + + static void _remove(String id) { + final next = _toasts.value.where((toast) => toast.id != id).toList(); + if (next.length == _toasts.value.length) { + return; + } + _toasts.value = next; + } +} + +class ToastViewport extends StatelessWidget { + const ToastViewport({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: true, + child: SafeArea( + child: ValueListenableBuilder>( + valueListenable: ToastService._toasts, + builder: (context, toasts, _) { + if (toasts.isEmpty) { + return const SizedBox.shrink(); + } + + final media = MediaQuery.of(context); + final width = math.min(320.0, media.size.width - 32); + + return Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: 16, bottom: 16), + child: SizedBox( + width: width > 0 ? width : 320, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (final toast in toasts) + Padding( + padding: const EdgeInsets.only(top: 8), + child: _ToastCard(item: toast), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +class _ToastCard extends StatefulWidget { + const _ToastCard({required this.item}); + + final _ToastItem item; + + @override + State<_ToastCard> createState() => _ToastCardState(); +} + +class _ToastCardState extends State<_ToastCard> { + bool _visible = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + setState(() { + _visible = true; + }); + }); + } + + @override + Widget build(BuildContext context) { + final scheme = _toastColorScheme(widget.item.type); + final icon = _toastIcon(widget.item.type); + + return AnimatedSlide( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + offset: _visible ? Offset.zero : const Offset(1, 0), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 220), + opacity: _visible ? 1 : 0, + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: scheme.border), + boxShadow: const [ + BoxShadow( + color: Color(0x26000000), + blurRadius: 16, + offset: Offset(0, 6), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 20, color: scheme.foreground), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.item.message, + style: TextStyle( + color: scheme.foreground, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.2, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + _ToastColorScheme _toastColorScheme(ToastType type) { + switch (type) { + case ToastType.success: + return const _ToastColorScheme( + background: Color(0xFFECFDF5), + border: Color(0xFFA7F3D0), + foreground: Color(0xFF065F46), + ); + case ToastType.error: + return const _ToastColorScheme( + background: Color(0xFFFFF1F2), + border: Color(0xFFFDA4AF), + foreground: Color(0xFF9F1239), + ); + case ToastType.info: + return const _ToastColorScheme( + background: Color(0xFFEFF6FF), + border: Color(0xFFBFDBFE), + foreground: Color(0xFF1E40AF), + ); + } + } + + IconData _toastIcon(ToastType type) { + switch (type) { + case ToastType.success: + return Icons.check_circle_outline; + case ToastType.error: + return Icons.error_outline; + case ToastType.info: + return Icons.info_outline; + } + } +} + +class _ToastColorScheme { + const _ToastColorScheme({ + required this.background, + required this.border, + required this.foreground, + }); + + final Color background; + final Color border; + final Color foreground; +} diff --git a/userfront/lib/features/admin/presentation/create_user_screen.dart b/userfront/lib/features/admin/presentation/create_user_screen.dart index 368cc681..e08e94d5 100644 --- a/userfront/lib/features/admin/presentation/create_user_screen.dart +++ b/userfront/lib/features/admin/presentation/create_user_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/i18n/locale_utils.dart'; +import '../../../../core/ui/toast_service.dart'; class CreateUserScreen extends StatefulWidget { const CreateUserScreen({super.key}); @@ -86,12 +87,7 @@ class _CreateUserScreenState extends State { } } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invalid Password. Access Denied.'), - backgroundColor: Colors.red, - ), - ); + ToastService.error('Invalid Password. Access Denied.'); context.go(buildLocalizedHomePath(Uri.base)); // Kick out } } @@ -144,12 +140,7 @@ class _CreateUserScreenState extends State { ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('User created successfully!'), - backgroundColor: Colors.green, - ), - ); + ToastService.success('User created successfully!'); _formKey.currentState!.reset(); _loginIdController.clear(); _emailController.clear(); @@ -158,9 +149,7 @@ class _CreateUserScreenState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), - ); + ToastService.error('Error: $e'); } } finally { if (mounted) setState(() => _isLoading = false); diff --git a/userfront/lib/features/admin/presentation/user_management_screen.dart b/userfront/lib/features/admin/presentation/user_management_screen.dart index 50274f13..d1d4168f 100644 --- a/userfront/lib/features/admin/presentation/user_management_screen.dart +++ b/userfront/lib/features/admin/presentation/user_management_screen.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'dart:async'; import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/i18n/locale_utils.dart'; +import '../../../../core/ui/toast_service.dart'; class UserManagementScreen extends StatefulWidget { const UserManagementScreen({super.key}); @@ -108,12 +109,7 @@ class _UserManagementScreenState extends State } } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invalid Password'), - backgroundColor: Colors.red, - ), - ); + ToastService.error('Invalid Password'); context.go(buildLocalizedHomePath(Uri.base)); } } @@ -343,16 +339,12 @@ class _UserManagementScreenState extends State // --- UI Helpers --- void _showError(String msg) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red)); + ToastService.error(msg); } void _showSuccess(String msg) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green)); + ToastService.success(msg); } @override diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 9d7d8003..0d3acd05 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -4,6 +4,7 @@ import 'package:userfront/i18n.dart'; import 'package:userfront/core/i18n/locale_utils.dart'; import 'package:userfront/core/services/auth_proxy_service.dart'; import 'package:userfront/core/services/web_window.dart'; +import 'package:userfront/core/ui/toast_service.dart'; class ConsentScreen extends StatefulWidget { final String consentChallenge; @@ -187,16 +188,11 @@ class _ConsentScreenState extends State { } catch (e) { setState(() => _isSubmitting = false); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.consent.cancel.error', - fallback: - 'An error occurred while cancelling consent: {{error}}', - params: {'error': '$e'}, - ), - ), + ToastService.error( + tr( + 'msg.userfront.consent.cancel.error', + fallback: 'An error occurred while cancelling consent: {{error}}', + params: {'error': '$e'}, ), ); } @@ -237,10 +233,13 @@ class _ConsentScreenState extends State { } Widget _buildConsentCard(BuildContext context) { - final clientName = - _consentInfo?['client']?['client_name'] ?? - tr('msg.userfront.consent.client_unknown'); - final clientId = _consentInfo?['client']?['client_id'] ?? '-'; + final clientRawName = _consentInfo?['client']?['client_name'] as String?; + final clientId = _consentInfo?['client']?['client_id'] as String? ?? '-'; + final clientName = (clientRawName != null && clientRawName.isNotEmpty) + ? clientRawName + : (clientId != '-' + ? clientId + : tr('msg.userfront.consent.client_unknown')); final clientLogo = _consentInfo?['client']?['logo_uri']; final requestedScopes = (_consentInfo?['requested_scope'] as List?)?.cast() ?? @@ -419,7 +418,7 @@ class _ConsentScreenState extends State { ) : Text( tr('ui.userfront.consent.accept'), - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), diff --git a/userfront/lib/features/auth/presentation/forgot_password_screen.dart b/userfront/lib/features/auth/presentation/forgot_password_screen.dart index b3fe1d96..8c01e4cd 100644 --- a/userfront/lib/features/auth/presentation/forgot_password_screen.dart +++ b/userfront/lib/features/auth/presentation/forgot_password_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/ui/toast_service.dart'; import 'package:userfront/i18n.dart'; class ForgotPasswordScreen extends StatefulWidget { @@ -46,12 +47,7 @@ class _ForgotPasswordScreenState extends State { drySend: _drySendEnabled, ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.forgot.sent')), - backgroundColor: Colors.green, - ), - ); + ToastService.success(tr('msg.userfront.forgot.sent')); Navigator.of(context).pop(); } } catch (e) { @@ -68,9 +64,7 @@ class _ForgotPasswordScreenState extends State { } void _showError(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.red), - ); + ToastService.error(message); } bool _parseBoolParam(String? value) { diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 906b7ba5..dad569bf 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -18,6 +19,7 @@ import '../domain/cookie_session_policy.dart'; import '../domain/login_link_route_policy.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../../core/services/web_window.dart'; +import '../../../core/ui/toast_service.dart'; class LoginScreen extends ConsumerStatefulWidget { final String? verificationToken; @@ -42,8 +44,10 @@ class _LoginScreenState extends ConsumerState final TextEditingController _passwordLoginIdController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); + final FocusNode _passwordFocusNode = FocusNode(); String? _redirectUrl; String? _loginChallenge; + bool _isPasswordCapsLockOn = false; // QR Login Variables String? _qrImageBase64; @@ -92,6 +96,8 @@ class _LoginScreenState extends ConsumerState _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv; _redirectUrl = widget.redirectUrl; + _passwordFocusNode.addListener(_handlePasswordFocusChange); + HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent); WidgetsBinding.instance.addPostFrameCallback((_) async { final uri = Uri.base; @@ -153,6 +159,40 @@ class _LoginScreenState extends ConsumerState }); } + void _handlePasswordFocusChange() { + if (!mounted) { + return; + } + if (_passwordFocusNode.hasFocus) { + _syncPasswordCapsLockState(); + return; + } + if (_isPasswordCapsLockOn) { + setState(() { + _isPasswordCapsLockOn = false; + }); + } + } + + bool _handleHardwareKeyEvent(KeyEvent event) { + if (_passwordFocusNode.hasFocus) { + _syncPasswordCapsLockState(); + } + return false; + } + + void _syncPasswordCapsLockState() { + final isEnabled = HardwareKeyboard.instance.lockModesEnabled.contains( + KeyboardLockMode.capsLock, + ); + if (!mounted || isEnabled == _isPasswordCapsLockOn) { + return; + } + setState(() { + _isPasswordCapsLockOn = isEnabled; + }); + } + Future _tryCookieSession({bool silent = true}) async { final loginChallenge = _loginChallenge; final token = AuthTokenStore.getToken(); @@ -935,6 +975,10 @@ class _LoginScreenState extends ConsumerState _linkIdController.dispose(); _passwordLoginIdController.dispose(); _passwordController.dispose(); + _passwordFocusNode + ..removeListener(_handlePasswordFocusChange) + ..dispose(); + HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent); _shortCodePrefixController.dispose(); _shortCodeDigitsController.dispose(); _linkResendTimer?.cancel(); @@ -1153,9 +1197,7 @@ class _LoginScreenState extends ConsumerState void _showError(String message) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.red), - ); + ToastService.error(message); try { AuthProxyService.logError(message); } catch (e) { @@ -1165,9 +1207,7 @@ class _LoginScreenState extends ConsumerState void _showInfo(String message) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.green), - ); + ToastService.success(message); } void _logTokenDetails(String jwt) { @@ -1302,6 +1342,24 @@ class _LoginScreenState extends ConsumerState ); } + String _capsLockWarningText(BuildContext context) { + const key = 'msg.userfront.login.password.caps_lock_on'; + final languageCode = Localizations.localeOf(context).languageCode; + if (languageCode == 'ko') { + final translated = tr(key); + if (translated != key) { + return translated; + } + return 'Caps Lock이 켜져 있습니다.'; + } + + final translated = tr(key, fallback: 'Caps Lock is on.'); + if (translated != key) { + return translated; + } + return 'Caps Lock is on.'; + } + @override Widget build(BuildContext context) { if (_verificationOnly && _verificationApproved) { @@ -1413,6 +1471,7 @@ class _LoginScreenState extends ConsumerState key: const ValueKey( 'password_login_password_input', ), + focusNode: _passwordFocusNode, controller: _passwordController, obscureText: true, decoration: InputDecoration( @@ -1426,6 +1485,29 @@ class _LoginScreenState extends ConsumerState ), onSubmitted: (_) => _handlePasswordLogin(), ), + if (_isPasswordCapsLockOn) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.keyboard_capslock_rounded, + size: 18, + color: Colors.orange, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _capsLockWarningText(context), + style: const TextStyle( + color: Colors.orange, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], const SizedBox(height: 24), FilledButton( key: const ValueKey( diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart b/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart index cd524661..75962f23 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen_stub.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:userfront/i18n.dart'; +import 'package:userfront/core/ui/toast_service.dart'; import 'qr_scan_route.dart'; @@ -23,15 +24,8 @@ class _QRScanScreenState extends State { void _submit() { final raw = _controller.text.trim(); if (raw.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.qr.permission_required', - fallback: '카메라 권한이 필요합니다.', - ), - ), - ), + ToastService.info( + tr('msg.userfront.qr.permission_required', fallback: '카메라 권한이 필요합니다.'), ); return; } diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index d9ebcb39..938831a2 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/ui/toast_service.dart'; import 'package:userfront/i18n.dart'; class ResetPasswordScreen extends StatefulWidget { @@ -84,12 +85,7 @@ class _ResetPasswordScreenState extends State { ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.reset.success')), - backgroundColor: Colors.green, - ), - ); + ToastService.success(tr('msg.userfront.reset.success')); context.go(buildLocalizedSigninPath(Uri.base)); } } catch (e) { @@ -109,9 +105,7 @@ class _ResetPasswordScreenState extends State { } void _showError(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.red), - ); + ToastService.error(message); } String _buildPolicyDescription() { diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 708e4d5e..0a406182 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -14,6 +15,17 @@ class SignupScreen extends StatefulWidget { } class _SignupScreenState extends State { + static const _signupInk = Color(0xFF111827); + static const _signupBorder = Color(0xFFE5E7EB); + static const _signupSurface = Color(0xFFF8FAFC); + static const _signupMuted = Color(0xFF6B7280); + static const _signupDone = Color(0xFF0F766E); + static const _signupDoneSurface = Color(0xFFECFDF5); + static const _agreementDesktopBreakpoint = 960.0; + static const _agreementCardMaxWidth = 880.0; + static const _stepIndicatorDesktopBreakpoint = 720.0; + static const _stepIndicatorMaxWidth = 880.0; + final _formKey = GlobalKey(); int _currentStep = 1; @@ -38,6 +50,8 @@ class _SignupScreenState extends State { bool _isLoading = false; Map? _policy; bool _isPolicyLoading = false; + bool _isPasswordObscured = true; + bool _isConfirmPasswordObscured = true; // Inline Errors String? _emailError; @@ -302,9 +316,7 @@ class _SignupScreenState extends State { phone: _phoneController.text.trim(), affiliationType: _affiliationType, companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null, - department: _deptController.text.trim().isEmpty - ? (_affiliationType == 'GENERAL' ? 'External' : '') - : _deptController.text.trim(), + department: _deptController.text.trim(), termsAccepted: true, ); if (mounted) _showSuccessDialog(); @@ -346,7 +358,10 @@ class _SignupScreenState extends State { content: Text(tr('msg.userfront.signup.success.body')), actions: [ TextButton( - onPressed: () => context.go(buildLocalizedSigninPath(Uri.base)), + onPressed: () { + Navigator.of(context).pop(); + context.go(buildLocalizedSigninPath(Uri.base)); + }, child: Text(tr('ui.userfront.signup.success.action')), ), ], @@ -357,165 +372,455 @@ class _SignupScreenState extends State { // --- UI Components --- Widget _buildStepIndicator() { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Row( - children: [ - _stepCircle(1, tr('ui.userfront.signup.steps.agreement')), - _stepLine(1), - _stepCircle(2, tr('ui.userfront.signup.steps.verify')), - _stepLine(2), - _stepCircle(3, tr('ui.userfront.signup.steps.profile')), - _stepLine(3), - _stepCircle(4, tr('ui.userfront.signup.steps.password')), - ], - ), + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = + constraints.maxWidth >= _stepIndicatorDesktopBreakpoint; + final indicatorWidth = isDesktop + ? math.min(constraints.maxWidth, _stepIndicatorMaxWidth) + : constraints.maxWidth; + + return Padding( + padding: EdgeInsets.symmetric(vertical: isDesktop ? 24 : 20), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: indicatorWidth, + child: Row( + children: [ + _stepCircle( + 1, + tr('ui.userfront.signup.steps.agreement'), + isDesktop: isDesktop, + ), + _stepLine(1, isDesktop: isDesktop), + _stepCircle( + 2, + tr('ui.userfront.signup.steps.verify'), + isDesktop: isDesktop, + ), + _stepLine(2, isDesktop: isDesktop), + _stepCircle( + 3, + tr('ui.userfront.signup.steps.profile'), + isDesktop: isDesktop, + ), + _stepLine(3, isDesktop: isDesktop), + _stepCircle( + 4, + tr('ui.userfront.signup.steps.password'), + isDesktop: isDesktop, + ), + ], + ), + ), + ), + ); + }, ); } - Widget _stepCircle(int step, String label) { - bool isDone = _currentStep > step; - bool isCurrent = _currentStep == step; - return Column( + Widget _stepCircle(int step, String label, {required bool isDesktop}) { + final isDone = _currentStep > step; + final isCurrent = _currentStep == step; + final circleRadius = isDesktop ? 17.0 : 12.0; + final labelStyle = TextStyle( + fontSize: isDesktop ? 12 : 9, + color: isCurrent ? _signupInk : (isDone ? _signupDone : _signupMuted), + fontWeight: isCurrent || isDone ? FontWeight.w700 : FontWeight.w500, + height: 1.2, + ); + + final stepWidget = Column( + mainAxisSize: MainAxisSize.min, children: [ - CircleAvatar( - radius: 12, - backgroundColor: isDone - ? Colors.green - : (isCurrent ? Colors.black : Colors.grey[300]), - child: isDone - ? const Icon(Icons.check, size: 14, color: Colors.white) - : Text( - '$step', - style: TextStyle( - color: isCurrent ? Colors.white : Colors.black54, - fontSize: 10, + AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: circleRadius * 2, + height: circleRadius * 2, + decoration: BoxDecoration( + color: isDone + ? _signupDone + : (isCurrent ? _signupInk : _signupSurface), + shape: BoxShape.circle, + border: Border.all( + color: isDone + ? _signupDone + : (isCurrent ? _signupInk : _signupBorder), + width: isCurrent ? 1.5 : 1, + ), + boxShadow: isDesktop && (isCurrent || isDone) + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 14, + offset: Offset(0, 8), + ), + ] + : const [], + ), + child: Center( + child: isDone + ? Icon( + Icons.check, + size: isDesktop ? 18 : 14, + color: Colors.white, + ) + : Text( + '$step', + style: TextStyle( + color: isCurrent ? Colors.white : _signupMuted, + fontSize: isDesktop ? 13 : 10, + fontWeight: FontWeight.w700, + ), ), - ), - ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - fontSize: 9, - color: isCurrent ? Colors.black : Colors.grey, - fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, ), ), + SizedBox(height: isDesktop ? 8 : 4), + Text(label, textAlign: TextAlign.center, style: labelStyle), ], ); + + if (!isDesktop) { + return stepWidget; + } + + return SizedBox(width: 96, child: stepWidget); } - Widget _stepLine(int afterStep) { - return Expanded( - child: Container( - margin: const EdgeInsets.only(bottom: 16, left: 2, right: 2), - height: 1.5, - color: _currentStep > afterStep ? Colors.green : Colors.grey[300], + Widget _stepLine(int afterStep, {required bool isDesktop}) { + final line = Container( + margin: EdgeInsets.only( + bottom: isDesktop ? 26 : 16, + left: isDesktop ? 10 : 2, + right: isDesktop ? 10 : 2, + ), + height: isDesktop ? 2 : 1.5, + decoration: BoxDecoration( + color: _currentStep > afterStep ? _signupDoneSurface : _signupBorder, + borderRadius: BorderRadius.circular(999), + ), + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: _currentStep > afterStep ? 1 : 0, + child: Container( + decoration: BoxDecoration( + color: _signupDone, + borderRadius: BorderRadius.circular(999), + ), + ), + ), ), ); + + if (isDesktop) { + return Expanded(child: line); + } + + return Expanded(child: line); } Widget _buildStepAgreement() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.agreement.title'), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - height: 1.3, - ), - ), - const SizedBox(height: 24), - // 모두 동의 버튼 - Container( - decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey[200]!), - ), - child: CheckboxListTile( - title: Text( - tr('ui.userfront.signup.agreement.all'), - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.agreement.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + Text( + tr('msg.userfront.signup.agreement.description'), + style: TextStyle( + fontSize: isDesktop ? 15 : 14, + height: 1.6, + color: _signupMuted, + ), + ), + SizedBox(height: isDesktop ? 28 : 24), + _buildAgreementSummaryCard(isDesktop: isDesktop), + const SizedBox(height: 18), + _agreementSection( + title: tr('ui.userfront.signup.agreement.tos_title'), + summary: tr('msg.userfront.signup.agreement.tos_summary'), + content: _tosText, + value: _termsAccepted, + isDesktop: isDesktop, + onChanged: (val) => + setState(() => _termsAccepted = val ?? false), + ), + const SizedBox(height: 18), + _agreementSection( + title: tr('ui.userfront.signup.agreement.privacy_title'), + summary: tr( + 'msg.userfront.signup.agreement.privacy_summary', + ), + content: _privacyText, + value: _privacyAccepted, + isDesktop: isDesktop, + onChanged: (val) => + setState(() => _privacyAccepted = val ?? false), + ), + ], + ), + ), ), - value: _termsAccepted && _privacyAccepted, - onChanged: (val) { - setState(() { - _termsAccepted = val!; - _privacyAccepted = val; - }); - }, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Colors.black, ), + ); + }, + ); + } + + Widget _buildAgreementSummaryCard({required bool isDesktop}) { + final acceptedCount = [ + _termsAccepted, + _privacyAccepted, + ].where((accepted) => accepted).length; + + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CheckboxListTile( + title: Text( + tr('ui.userfront.signup.agreement.all'), + style: TextStyle( + fontSize: isDesktop ? 17 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + tr('msg.userfront.signup.agreement.all_hint'), + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + ), + value: _termsAccepted && _privacyAccepted, + onChanged: (val) { + setState(() { + final next = val ?? false; + _termsAccepted = next; + _privacyAccepted = next; + }); + }, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: _signupInk, + ), + const SizedBox(height: 8), + Text( + tr( + 'msg.userfront.signup.agreement.progress', + params: {'count': '$acceptedCount', 'total': '2'}, + ), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _signupMuted, + ), + ), + ], ), - const SizedBox(height: 16), - _agreementSection( - title: tr('ui.userfront.signup.agreement.tos_title'), - content: _tosText, - value: _termsAccepted, - onChanged: (val) => setState(() => _termsAccepted = val!), - ), - const SizedBox(height: 16), - _agreementSection( - title: tr('ui.userfront.signup.agreement.privacy_title'), - content: _privacyText, - value: _privacyAccepted, - onChanged: (val) => setState(() => _privacyAccepted = val!), - ), - ], + ), ); } Widget _agreementSection({ required String title, + required String summary, required String content, required bool value, + required bool isDesktop, required ValueChanged onChanged, }) { - return Column( - children: [ - CheckboxListTile( - title: Text( - title, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), - value: value, - onChanged: onChanged, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - activeColor: Colors.black, - ), - Container( - height: 120, - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(4), - ), - child: SingleChildScrollView( - child: Text( - content, - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - height: 1.5, + final contentHeight = isDesktop ? 260.0 : 148.0; + + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: CheckboxListTile( + title: Text( + title, + style: TextStyle( + fontSize: isDesktop ? 16 : 14, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + summary, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + ), + value: value, + onChanged: onChanged, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + activeColor: _signupInk, + ), + ), + const SizedBox(width: 12), + _buildRequiredBadge(), + ], + ), + const SizedBox(height: 12), + Container( + height: contentHeight, + width: double.infinity, + padding: EdgeInsets.all(isDesktop ? 18 : 14), + decoration: BoxDecoration( + color: _signupSurface, + border: Border.all(color: _signupBorder), + borderRadius: BorderRadius.circular(14), + ), + child: SingleChildScrollView( + child: SelectableText( + content, + style: TextStyle( + fontSize: isDesktop ? 13 : 12, + color: _signupMuted, + height: 1.6, + ), + ), ), ), - ), + ], ), - ], + ), ); } - static String get _tosText => tr( - 'msg.userfront.signup.tos_full', - fallback: """ + Widget _buildRequiredBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Text( + tr('ui.userfront.signup.agreement.required'), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF3730A3), + ), + ), + ); + } + + Widget _buildStepBody() { + final stepChild = _currentStep == 1 + ? _buildStepAgreement() + : (_currentStep == 2 + ? _buildStepAuth() + : (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())); + + return LayoutBuilder( + builder: (context, constraints) { + final isAgreementStep = _currentStep == 1; + final horizontalPadding = + isAgreementStep && + constraints.maxWidth >= _agreementDesktopBreakpoint + ? 40.0 + : 24.0; + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + horizontalPadding, + 24, + horizontalPadding, + 32, + ), + child: Form(key: _formKey, child: stepChild), + ); + }, + ); + } + + static String _resolveAgreementText( + String key, { + required String fallback, + required Set placeholders, + }) { + final localized = tr(key, fallback: '').trim(); + if (localized.isEmpty || placeholders.contains(localized)) { + return fallback; + } + return localized; + } + + static String get _tosText { + const fallback = """ 바론 소프트웨어 이용약관 제1장 총칙 @@ -585,12 +890,16 @@ class _SignupScreenState extends State { 본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다. 부칙 본 약관은 2024년 10월 1일부터 시행됩니다. -""", - ); +"""; + return _resolveAgreementText( + 'msg.userfront.signup.tos_full', + fallback: fallback, + placeholders: {'서비스 이용약관 전문...', 'Tos Full'}, + ); + } - static String get _privacyText => tr( - 'msg.userfront.signup.privacy_full', - fallback: """ + static String get _privacyText { + const fallback = """ 개인정보 수집 및 이용 동의 바론서비스 개인정보처리방침 @@ -698,306 +1007,702 @@ class _SignupScreenState extends State { 회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다. 제8조 (기타) 본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다. -""", - ); +"""; + return _resolveAgreementText( + 'msg.userfront.signup.privacy_full', + fallback: fallback, + placeholders: {'개인정보 수집 및 이용 동의 전문...', 'Privacy Full'}, + ); + } Widget _buildStepAuth() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.auth.title'), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - // 가족사 이메일 안내 문구 - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(6), + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.auth.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildAuthNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildVerificationCard( + isDesktop: isDesktop, + icon: Icons.alternate_email_rounded, + title: tr('ui.userfront.signup.auth.email.title'), + label: tr('ui.userfront.signup.auth.email.label'), + hintText: 'example@hanmaceng.co.kr', + controller: _emailController, + errorText: _emailError, + readOnly: _isEmailVerified, + buttonLabel: _emailSeconds > 0 + ? tr('ui.common.resend') + : tr('ui.userfront.signup.auth.request_code'), + buttonEnabled: !_isEmailVerified && !_isLoading, + onRequestCode: _sendEmailCode, + verificationController: _emailCodeController, + verificationVisible: + _emailSeconds > 0 && !_isEmailVerified, + verificationCountdown: _formatTime(_emailSeconds), + verified: _isEmailVerified, + verifiedText: tr('msg.userfront.signup.email.verified'), + keyboardType: TextInputType.emailAddress, + onChanged: _checkEmailAffiliation, + onVerificationChanged: (val) { + if (val.length == 6) _verifyEmailCode(); + }, + ), + const SizedBox(height: 18), + _buildVerificationCard( + isDesktop: isDesktop, + icon: Icons.phone_iphone_rounded, + title: tr('ui.userfront.signup.phone.title'), + label: tr('ui.userfront.signup.phone.label'), + controller: _phoneController, + errorText: _phoneError, + readOnly: _isPhoneVerified, + buttonLabel: _phoneSeconds > 0 + ? tr('ui.common.resend') + : tr('ui.userfront.signup.auth.request_code'), + buttonEnabled: !_isPhoneVerified && !_isLoading, + onRequestCode: _sendPhoneCode, + verificationController: _phoneCodeController, + verificationVisible: + _phoneSeconds > 0 && !_isPhoneVerified, + verificationCountdown: _formatTime(_phoneSeconds), + verified: _isPhoneVerified, + verifiedText: tr('msg.userfront.signup.phone.verified'), + keyboardType: TextInputType.phone, + onVerificationChanged: (val) { + if (val.length == 6) _verifyPhoneCode(); + }, + ), + ], + ), + ), + ), ), - child: Row( - children: [ - const Icon(Icons.info_outline, size: 16, color: Colors.blue), - const SizedBox(width: 8), - Expanded( - child: Text( - tr('msg.userfront.signup.auth.affiliate_notice'), - style: const TextStyle( - fontSize: 12, - color: Colors.blue, - fontWeight: FontWeight.w500, + ); + }, + ); + } + + Widget _buildAuthNoticeCard({required bool isDesktop}) { + return DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFDBEAFE), + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.info_outline, + size: 18, + color: Color(0xFF1D4ED8), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + tr('msg.userfront.signup.auth.affiliate_notice'), + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: const Color(0xFF1E3A8A), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildVerificationCard({ + required bool isDesktop, + required IconData icon, + required String title, + required String label, + required TextEditingController controller, + required String buttonLabel, + required bool buttonEnabled, + required Future Function() onRequestCode, + required TextEditingController verificationController, + required bool verificationVisible, + required String verificationCountdown, + required bool verified, + required String verifiedText, + String? hintText, + String? errorText, + bool readOnly = false, + TextInputType? keyboardType, + ValueChanged? onChanged, + ValueChanged? onVerificationChanged, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + width: isDesktop ? 40 : 36, + height: isDesktop ? 40 : 36, + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _signupBorder), + ), + child: Icon(icon, size: 18, color: _signupInk), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: isDesktop ? 18 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), ), ), - ), - ], - ), - ), - const SizedBox(height: 24), - Text( - tr('ui.userfront.signup.auth.email.title'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _emailController, - onChanged: _checkEmailAffiliation, // 도메인 실시간 체크 - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.email.label'), - border: const OutlineInputBorder(), - errorText: _emailError, - hintText: 'example@hanmaceng.co.kr', - ), - readOnly: _isEmailVerified, - ), + if (verified) _buildVerifiedBadge(verifiedText), + ], ), - const SizedBox(width: 8), - SizedBox( - height: 55, - child: ElevatedButton( - onPressed: (_isEmailVerified || _isLoading) - ? null - : _sendEmailCode, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[100], - foregroundColor: Colors.black, - elevation: 0, + SizedBox(height: isDesktop ? 20 : 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: TextFormField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + errorText: errorText, + hintText: hintText, + ), + readOnly: readOnly, + keyboardType: keyboardType, + ), + ), ), - child: Text( - _emailSeconds > 0 - ? tr('ui.common.resend') - : tr('ui.userfront.signup.auth.request_code'), + const SizedBox(width: 10), + SizedBox( + height: 52, + width: isDesktop ? 108 : null, + child: FilledButton( + onPressed: buttonEnabled ? onRequestCode : null, + style: FilledButton.styleFrom( + backgroundColor: _signupInk, + foregroundColor: Colors.white, + disabledBackgroundColor: const Color(0xFFE5E7EB), + disabledForegroundColor: const Color(0xFF9CA3AF), + ), + child: Text(buttonLabel), + ), ), + ], + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (verificationVisible) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: TextFormField( + controller: verificationController, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.auth.code_label', + ), + suffixText: verificationCountdown, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onChanged: onVerificationChanged, + ), + ), + ), + const SizedBox(width: 10), + SizedBox(width: isDesktop ? 108 : 0), + ], + ), + ], + if (verified) ...[ + const SizedBox(height: 12), + _buildVerificationStatus(verifiedText), + ], + ], ), ), ], ), - if (_emailSeconds > 0 && !_isEmailVerified) ...[ - const SizedBox(height: 8), - TextFormField( - controller: _emailCodeController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.code_label'), - suffixText: _formatTime(_emailSeconds), - border: const OutlineInputBorder(), + ), + ); + } + + Widget _buildVerifiedBadge(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: _signupDoneSurface, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFA7F3D0)), + ), + child: Text( + text.replaceFirst('✅ ', ''), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: _signupDone, + ), + ), + ); + } + + Widget _buildVerificationStatus(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: _signupDoneSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFA7F3D0)), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, size: 18, color: _signupDone), + const SizedBox(width: 8), + Expanded( + child: Text( + text.replaceFirst('✅ ', ''), + style: const TextStyle( + color: _signupDone, + fontSize: 13, + fontWeight: FontWeight.w700, + ), ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(6), - ], - onChanged: (val) { - if (val.length == 6) _verifyEmailCode(); - }, ), ], - if (_isEmailVerified) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - tr('msg.userfront.signup.email.verified'), - style: const TextStyle( - color: Colors.green, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 24), - Text( - tr('ui.userfront.signup.phone.title'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _phoneController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.phone.label'), - border: const OutlineInputBorder(), - errorText: _phoneError, - ), - readOnly: _isPhoneVerified, - keyboardType: TextInputType.phone, - ), - ), - const SizedBox(width: 8), - SizedBox( - height: 55, - child: ElevatedButton( - onPressed: (_isPhoneVerified || _isLoading) - ? null - : _sendPhoneCode, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[100], - foregroundColor: Colors.black, - elevation: 0, - ), - child: Text( - _phoneSeconds > 0 - ? tr('ui.common.resend') - : tr('ui.userfront.signup.auth.request_code'), - ), - ), - ), - ], - ), - if (_phoneSeconds > 0 && !_isPhoneVerified) ...[ - const SizedBox(height: 8), - TextFormField( - controller: _phoneCodeController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.code_label'), - suffixText: _formatTime(_phoneSeconds), - border: const OutlineInputBorder(), - ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(6), - ], - onChanged: (val) { - if (val.length == 6) _verifyPhoneCode(); - }, - ), - ], - if (_isPhoneVerified) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - tr('msg.userfront.signup.phone.verified'), - style: const TextStyle( - color: Colors.green, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ], + ), ); } Widget _buildStepInfo() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.profile.title'), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 24), - TextFormField( - controller: _nameController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.name'), - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - // 소속 유형 선택 (가족사 메일일 경우 비활성화) - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_affiliationType), - initialValue: _affiliationType, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.affiliation_type'), - border: const OutlineInputBorder(), - helperText: _isAffiliateEmail - ? tr('msg.userfront.signup.profile.affiliate_hint') - : null, + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], ), - items: [ - DropdownMenuItem( - value: 'GENERAL', - child: Text(tr('domain.affiliation.general')), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, ), - DropdownMenuItem( - value: 'AFFILIATE', - child: Text(tr('domain.affiliation.affiliate')), + 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: 18), + _buildProfileFieldGroup( + title: tr('ui.userfront.signup.profile.affiliation_type'), + description: _isAffiliateEmail + ? tr('msg.userfront.signup.profile.affiliate_hint') + : '소속 유형과 회사 정보를 입력합니다.', + isDesktop: isDesktop, + trailing: _isAffiliateEmail + ? _buildAutoDetectedBadge() + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AbsorbPointer( + absorbing: _isAffiliateEmail, + child: Opacity( + opacity: _isAffiliateEmail ? 0.7 : 1.0, + child: DropdownButtonFormField( + key: ValueKey(_affiliationType), + initialValue: _affiliationType, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.affiliation_type', + ), + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 'GENERAL', + child: Text( + tr('domain.affiliation.general'), + ), + ), + DropdownMenuItem( + value: 'AFFILIATE', + child: Text( + tr('domain.affiliation.affiliate'), + ), + ), + ], + onChanged: _isAffiliateEmail + ? null + : (val) { + if (val == null) { + return; + } + setState(() { + _affiliationType = val; + }); + }, + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_affiliationType == 'AFFILIATE') ...[ + const SizedBox(height: 14), + AbsorbPointer( + absorbing: _isAffiliateEmail, + child: Opacity( + opacity: _isAffiliateEmail ? 0.7 : 1.0, + child: DropdownButtonFormField( + key: ValueKey(_companyCode ?? 'none'), + initialValue: _companyCode, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.company', + ), + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 'HANMAC', + child: Text( + tr('domain.company.hanmac'), + ), + ), + DropdownMenuItem( + value: 'SAMAN', + child: Text( + tr('domain.company.saman'), + ), + ), + DropdownMenuItem( + value: 'PTC', + child: Text( + tr( + 'domain.company.ptc', + fallback: 'PTC', + ), + ), + ), + DropdownMenuItem( + value: 'JANGHEON', + child: Text( + tr('domain.company.jangheon'), + ), + ), + DropdownMenuItem( + value: 'BARON', + child: Text( + tr('domain.company.baron'), + ), + ), + DropdownMenuItem( + value: 'HALLA', + child: Text( + tr('domain.company.halla'), + ), + ), + ], + onChanged: _isAffiliateEmail + ? null + : (val) => setState( + () => _companyCode = val, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + const SizedBox(height: 18), + _buildProfileFieldGroup( + title: _affiliationType == 'AFFILIATE' + ? tr('ui.userfront.signup.profile.department') + : 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(), + ), + ), + ), + ], ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildProfileInfoNoticeCard({required bool isDesktop}) { + final description = _isAffiliateEmail + ? '가족사 이메일이 확인되어 소속 유형이 자동으로 고정됩니다.' + : '회원가입 후 사용할 기본 소속 정보를 입력합니다.'; + + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF4338CA), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + description, + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: _signupInk, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildProfileFieldGroup({ + required String title, + required String description, + required bool isDesktop, + required Widget child, + Widget? trailing, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: isDesktop ? 17 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + const SizedBox(height: 6), + Text( + description, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + ], + ), + ), + if (trailing != null) ...[const SizedBox(width: 12), trailing], ], - onChanged: _isAffiliateEmail - ? null - : (val) { - if (val == null) { - return; - } - setState(() { - _affiliationType = val; - }); - }, ), - ), + SizedBox(height: isDesktop ? 18 : 14), + child, + ], ), - const SizedBox(height: 16), - // 가족사 선택 (가족사 메일일 경우 비활성화) - if (_affiliationType == 'AFFILIATE') ...[ - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_companyCode ?? 'none'), - initialValue: _companyCode, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.company'), - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: 'HANMAC', - child: Text(tr('domain.company.hanmac')), - ), - DropdownMenuItem( - value: 'SAMAN', - child: Text(tr('domain.company.saman')), - ), - DropdownMenuItem( - value: 'PTC', - child: Text(tr('domain.company.ptc', fallback: 'PTC')), - ), - DropdownMenuItem( - value: 'JANGHEON', - child: Text(tr('domain.company.jangheon')), - ), - DropdownMenuItem( - value: 'BARON', - child: Text(tr('domain.company.baron')), - ), - DropdownMenuItem( - value: 'HALLA', - child: Text(tr('domain.company.halla')), - ), - ], - onChanged: _isAffiliateEmail - ? null - : (val) => setState(() => _companyCode = val), - ), - ), - ), - const SizedBox(height: 16), - ], - 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(), - ), + ), + ); + } + + Widget _buildAutoDetectedBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: const Text( + '자동 선택', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF4338CA), ), - ], + ), ); } @@ -1046,7 +1751,7 @@ class _SignupScreenState extends State { } Widget _buildStepPassword() { - String p = _passwordController.text; + final p = _passwordController.text; // Default Policy Fallback final minLength = (_policy?['minLength'] as int?) ?? 12; @@ -1056,120 +1761,285 @@ class _SignupScreenState extends State { final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; - bool hasLength = p.length >= minLength; - bool hasUpper = p.contains(RegExp(r'[A-Z]')); - bool hasLower = p.contains(RegExp(r'[a-z]')); - bool hasDigit = p.contains(RegExp(r'[0-9]')); - bool hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); + final hasLength = p.length >= minLength; + final hasUpper = p.contains(RegExp(r'[A-Z]')); + final hasLower = p.contains(RegExp(r'[a-z]')); + final hasDigit = p.contains(RegExp(r'[0-9]')); + final hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); int typeCount = 0; if (hasUpper) typeCount++; if (hasLower) typeCount++; if (hasDigit) typeCount++; if (hasSpecial) typeCount++; - bool hasTypeCount = minTypes <= 0 || typeCount >= minTypes; + final hasTypeCount = minTypes <= 0 || typeCount >= minTypes; + final passwordChecks = [ + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.min_length', + params: {'count': minLength.toString()}, + ), + hasLength, + ), + if (minTypes > 0) + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.min_types', + params: {'count': minTypes.toString()}, + ), + hasTypeCount, + ), + if (requiresUpper) + _cryptoCheck( + tr('msg.userfront.signup.password.rule.uppercase'), + hasUpper, + ), + if (requiresLower) + _cryptoCheck( + tr('msg.userfront.signup.password.rule.lowercase'), + hasLower, + ), + if (requiresNumber) + _cryptoCheck(tr('msg.userfront.signup.password.rule.number'), hasDigit), + if (requiresSymbol) + _cryptoCheck( + tr('msg.userfront.signup.password.rule.symbol'), + hasSpecial, + ), + ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.password.title'), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - // 비밀번호 정책 안내 박스 - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const Icon(Icons.security, size: 18, color: Colors.blue), - const SizedBox(width: 10), - Expanded( - child: Text( - _buildPolicyDescription(), - style: TextStyle( - fontSize: 12, - color: Colors.blue[800], - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - TextFormField( - controller: _passwordController, - obscureText: true, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.password.label'), - border: const OutlineInputBorder(), - errorText: _passwordError, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 10, - children: [ - _cryptoCheck( - tr( - 'msg.userfront.signup.password.rule.min_length', - params: {'count': minLength.toString()}, - ), - hasLength, + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, ), - if (minTypes > 0) - _cryptoCheck( - tr( - 'msg.userfront.signup.password.rule.min_types', - params: {'count': minTypes.toString()}, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.password.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildPasswordPolicyNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildPasswordFieldGroup( + title: tr('ui.userfront.signup.password.label'), + description: '보안 정책에 맞는 비밀번호를 입력합니다.', + isDesktop: isDesktop, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _passwordController, + obscureText: _isPasswordObscured, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.password.label', + ), + border: const OutlineInputBorder(), + errorText: _passwordError, + suffixIcon: IconButton( + onPressed: () { + setState(() { + _isPasswordObscured = !_isPasswordObscured; + }); + }, + icon: Icon( + _isPasswordObscured + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + ), + const SizedBox(height: 14), + _buildPasswordChecksCard( + checks: passwordChecks, + isDesktop: isDesktop, + ), + ], + ), + ), + const SizedBox(height: 18), + _buildPasswordFieldGroup( + title: tr('ui.userfront.signup.password.confirm_label'), + description: '입력한 비밀번호를 한 번 더 확인합니다.', + isDesktop: isDesktop, + child: TextFormField( + controller: _confirmPasswordController, + obscureText: _isConfirmPasswordObscured, + onChanged: (val) { + setState(() { + _confirmPasswordError = + (val != _passwordController.text) + ? tr('msg.userfront.signup.password.mismatch') + : null; + }); + }, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.password.confirm_label', + ), + border: const OutlineInputBorder(), + errorText: _confirmPasswordError, + suffixIcon: IconButton( + onPressed: () { + setState(() { + _isConfirmPasswordObscured = + !_isConfirmPasswordObscured; + }); + }, + icon: Icon( + _isConfirmPasswordObscured + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + ), + ), + ], ), - hasTypeCount, ), - if (requiresUpper) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.uppercase'), - hasUpper, + ), + ), + ); + }, + ); + } + + Widget _buildPasswordPolicyNoticeCard({required bool isDesktop}) { + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFDBEAFE), + borderRadius: BorderRadius.circular(999), ), - if (requiresLower) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.lowercase'), - hasLower, + child: const Icon( + Icons.security_rounded, + size: 18, + color: Color(0xFF1D4ED8), ), - if (requiresNumber) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.number'), - hasDigit, - ), - if (requiresSymbol) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.symbol'), - hasSpecial, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _buildPolicyDescription(), + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: const Color(0xFF1E3A8A), + fontWeight: FontWeight.w600, + ), ), + ), ], ), - const SizedBox(height: 16), - TextFormField( - controller: _confirmPasswordController, - obscureText: true, - onChanged: (val) { - setState(() { - _confirmPasswordError = (val != _passwordController.text) - ? tr('msg.userfront.signup.password.mismatch') - : null; - }); - }, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.password.confirm_label'), - border: const OutlineInputBorder(), - errorText: _confirmPasswordError, - ), + ), + ); + } + + Widget _buildPasswordFieldGroup({ + required String title, + required String description, + required bool isDesktop, + required Widget child, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: TextStyle( + fontSize: isDesktop ? 17 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + const SizedBox(height: 6), + Text( + description, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + SizedBox(height: isDesktop ? 18 : 14), + child, + ], ), - ], + ), + ); + } + + Widget _buildPasswordChecksCard({ + required List checks, + required bool isDesktop, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 16 : 14), + child: Wrap(spacing: 12, runSpacing: 10, children: checks), + ), ); } @@ -1233,21 +2103,7 @@ class _SignupScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20), child: _buildStepIndicator(), ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: _currentStep == 1 - ? _buildStepAgreement() - : (_currentStep == 2 - ? _buildStepAuth() - : (_currentStep == 3 - ? _buildStepInfo() - : _buildStepPassword())), - ), - ), - ), + Expanded(child: _buildStepBody()), Padding( padding: const EdgeInsets.all(24), child: Row( diff --git a/userfront/lib/features/dashboard/domain/session_time_resolver.dart b/userfront/lib/features/dashboard/domain/session_time_resolver.dart new file mode 100644 index 00000000..feb0210f --- /dev/null +++ b/userfront/lib/features/dashboard/domain/session_time_resolver.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import '../../profile/data/models/user_profile_model.dart'; + +DateTime? resolveDashboardSessionIssuedAt({ + String? token, + UserProfile? profile, +}) { + final tokenIssuedAt = _getJwtIssuedAt(token); + if (tokenIssuedAt != null) { + return tokenIssuedAt; + } + return _parseSessionAuthenticatedAt(profile?.sessionAuthenticatedAt); +} + +DateTime? _getJwtIssuedAt(String? token) { + if (token == null || token.isEmpty) { + return null; + } + try { + final parts = token.split('.'); + if (parts.length != 3) { + return null; + } + final payload = utf8.decode( + base64Url.decode(base64Url.normalize(parts[1])), + ); + final data = json.decode(payload) as Map; + final iatValue = data['iat'] ?? data['auth_time']; + if (iatValue is num) { + return DateTime.fromMillisecondsSinceEpoch( + iatValue.toInt() * 1000, + ).toLocal(); + } + } catch (_) { + return null; + } + return null; +} + +DateTime? _parseSessionAuthenticatedAt(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + try { + return DateTime.parse(value).toLocal(); + } catch (_) { + return null; + } +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 3e30856f..11f80c7a 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import '../domain/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; @@ -15,6 +16,7 @@ import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/widgets/language_selector.dart'; import '../../../../core/ui/layout_breakpoints.dart'; +import '../../../../core/ui/toast_service.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../domain/dashboard_providers.dart'; import '../domain/models.dart' hide LinkedRp; @@ -104,14 +106,10 @@ class _DashboardScreenState extends ConsumerState { try { await ref.read(linkedRpsProvider.notifier).revokeRp(clientId); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.dashboard.revoke.success', - params: {'app': appName}, - ), - ), + ToastService.success( + tr( + 'msg.userfront.dashboard.revoke.success', + params: {'app': appName}, ), ); setState(() { @@ -121,15 +119,8 @@ class _DashboardScreenState extends ConsumerState { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.dashboard.revoke.error', - params: {'error': '$e'}, - ), - ), - ), + ToastService.error( + tr('msg.userfront.dashboard.revoke.error', params: {'error': '$e'}), ); } } finally { @@ -414,32 +405,6 @@ class _DashboardScreenState extends ConsumerState { } } - DateTime? _getJwtIssuedAt() { - final token = AuthTokenStore.getToken(); - if (token == null || token.isEmpty) { - return null; - } - try { - final parts = token.split('.'); - if (parts.length != 3) { - return null; - } - final payload = utf8.decode( - base64Url.decode(base64Url.normalize(parts[1])), - ); - final data = json.decode(payload) as Map; - final iatValue = data['iat'] ?? data['auth_time']; - if (iatValue is num) { - return DateTime.fromMillisecondsSinceEpoch( - iatValue.toInt() * 1000, - ).toLocal(); - } - } catch (_) { - return null; - } - return null; - } - String _formatDateTime(DateTime dateTime) { final yyyy = dateTime.year.toString().padLeft(4, '0'); final mm = dateTime.month.toString().padLeft(2, '0'); @@ -547,12 +512,8 @@ class _DashboardScreenState extends ConsumerState { : () async { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr('msg.userfront.dashboard.session_id_copied'), - ), - ), + ToastService.info( + tr('msg.userfront.dashboard.session_id_copied'), ); } }, @@ -626,12 +587,8 @@ class _DashboardScreenState extends ConsumerState { : () async { await Clipboard.setData(ClipboardData(text: approvedSessionId)); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr('msg.userfront.dashboard.session_id_copied'), - ), - ), + ToastService.info( + tr('msg.userfront.dashboard.session_id_copied'), ); } }, @@ -730,11 +687,15 @@ class _DashboardScreenState extends ConsumerState { profile?.email ?? profile?.phone ?? tr('ui.userfront.profile.user_fallback', fallback: 'User'); - final departmentValue = profile?.department ?? ''; + final departmentValue = + profile?.tenant?.name ?? profile?.department ?? ''; final department = departmentValue.isNotEmpty ? departmentValue : tr('ui.userfront.profile.department_empty'); - final sessionIssuedAt = _getJwtIssuedAt(); + final sessionIssuedAt = resolveDashboardSessionIssuedAt( + token: AuthTokenStore.getToken(), + profile: profile, + ); return Scaffold( backgroundColor: _subtle, @@ -1280,7 +1241,6 @@ class _DashboardScreenState extends ConsumerState { cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () async { - final messenger = ScaffoldMessenger.of(context); final itemUrl = item.url; if (itemUrl != null && itemUrl.isNotEmpty) { final uri = Uri.parse(itemUrl); @@ -1290,18 +1250,10 @@ class _DashboardScreenState extends ConsumerState { await launchUrl(uri); return; } - messenger.showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.dashboard.link_open_error')), - ), - ); + ToastService.error(tr('msg.userfront.dashboard.link_open_error')); } else { if (!mounted) return; - messenger.showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.dashboard.link_missing')), - ), - ); + ToastService.info(tr('msg.userfront.dashboard.link_missing')); } }, child: opaqueCard, @@ -1489,9 +1441,12 @@ class _DashboardScreenState extends ConsumerState { } double _historySessionColumnWidth(double maxWidth) { - return math.max( - _historySessionMinWidth, - maxWidth - _historyOtherColumnsBaselineWidth, + return math.min( + 200.0, + math.max( + _historySessionMinWidth, + maxWidth - _historyOtherColumnsBaselineWidth, + ), ); } diff --git a/userfront/lib/features/profile/data/models/user_profile_model.dart b/userfront/lib/features/profile/data/models/user_profile_model.dart index 9485a836..eb73b30d 100644 --- a/userfront/lib/features/profile/data/models/user_profile_model.dart +++ b/userfront/lib/features/profile/data/models/user_profile_model.dart @@ -33,6 +33,7 @@ class UserProfile { final String department; final String affiliationType; final String companyCode; + final String? sessionAuthenticatedAt; final Map? metadata; final Tenant? tenant; @@ -44,6 +45,7 @@ class UserProfile { required this.department, required this.affiliationType, required this.companyCode, + this.sessionAuthenticatedAt, this.metadata, this.tenant, }); @@ -57,6 +59,7 @@ class UserProfile { department: json['department'] ?? '', affiliationType: json['affiliationType'] ?? '', companyCode: json['companyCode'] ?? '', + sessionAuthenticatedAt: json['sessionAuthenticatedAt'] as String?, metadata: json['metadata'] != null ? Map.from(json['metadata']) : null, @@ -73,6 +76,7 @@ class UserProfile { 'department': department, 'affiliationType': affiliationType, 'companyCode': companyCode, + 'sessionAuthenticatedAt': sessionAuthenticatedAt, 'metadata': metadata, 'tenant': tenant?.toJson(), }; @@ -87,6 +91,7 @@ class UserProfile { department: department ?? this.department, affiliationType: affiliationType, companyCode: companyCode, + sessionAuthenticatedAt: sessionAuthenticatedAt, tenant: tenant, ); } diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index dd741004..c489e2a8 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -7,6 +7,7 @@ import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/ui/layout_breakpoints.dart'; +import '../../../../core/ui/toast_service.dart'; import '../../../../core/widgets/language_selector.dart'; import '../../data/models/user_profile_model.dart'; import '../../domain/notifiers/profile_notifier.dart'; @@ -38,12 +39,8 @@ class _ProfilePageState extends ConsumerState { final FocusNode _departmentFocus = FocusNode(); final FocusNode _phoneFocus = FocusNode(); final FocusNode _phoneCodeFocus = FocusNode(); - bool _nameTouched = false; - bool _departmentTouched = false; - bool _phoneTouched = false; - bool _phoneCodeTouched = false; bool _isSavingField = false; - String? _skipAutoSaveField; + String? _fieldSaveError; String _initialPhone = ''; bool _isPhoneChanged = false; @@ -61,10 +58,6 @@ class _ProfilePageState extends ConsumerState { @override void initState() { super.initState(); - _nameFocus.addListener(_onNameFocusChange); - _departmentFocus.addListener(_onDepartmentFocusChange); - _phoneFocus.addListener(_onPhoneFocusChange); - _phoneCodeFocus.addListener(_onPhoneCodeFocusChange); } void _debugLog( @@ -83,63 +76,6 @@ class _ProfilePageState extends ConsumerState { _log.fine(parts.join(' ')); } - void _onNameFocusChange() { - if (!mounted) return; - if (!_nameFocus.hasFocus && _nameTouched) { - Future.microtask(() { - if (!mounted) return; - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _autoSaveIfEditing(profile, 'name'); - }); - } else if (_nameFocus.hasFocus) { - _nameTouched = true; - } - } - - void _onDepartmentFocusChange() { - if (!mounted) return; - _debugLog( - 'department_focus_change', - field: 'department', - hasFocus: _departmentFocus.hasFocus, - ); - if (!_departmentFocus.hasFocus && _departmentTouched) { - Future.microtask(() { - if (!mounted) return; - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _autoSaveIfEditing(profile, 'department'); - }); - } else if (_departmentFocus.hasFocus) { - _departmentTouched = true; - } - } - - void _onPhoneFocusChange() { - if (!mounted) return; - if (!_phoneFocus.hasFocus && _phoneTouched) { - Future.microtask(() { - if (!mounted) return; - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _handlePhoneFocusChange(profile); - }); - } else if (_phoneFocus.hasFocus) { - _phoneTouched = true; - } - } - - void _onPhoneCodeFocusChange() { - if (!mounted) return; - if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) { - Future.microtask(() { - if (!mounted) return; - final profile = ref.read(profileProvider).value ?? _cachedProfile; - if (profile != null) _handlePhoneFocusChange(profile); - }); - } else if (_phoneCodeFocus.hasFocus) { - _phoneCodeTouched = true; - } - } - @override void dispose() { _nameController?.dispose(); @@ -210,14 +146,13 @@ class _ProfilePageState extends ConsumerState { _isCodeSent = false; _isVerifying = false; _codeController?.clear(); - _phoneTouched = false; - _phoneCodeTouched = false; } void _startEditing(String field, UserProfile profile) { _debugLog('start_editing', field: field); setState(() { _editingField = field; + _fieldSaveError = null; if (field == 'name') { _nameController?.text = profile.name; } else if (field == 'department') { @@ -252,8 +187,7 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } _editingField = null; - _nameTouched = false; - _departmentTouched = false; + _fieldSaveError = null; }); } @@ -269,21 +203,15 @@ class _ProfilePageState extends ConsumerState { _isVerifying = false; }); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.phone.code_sent'))), - ); + ToastService.info(tr('msg.userfront.profile.phone.code_sent')); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.profile.phone.send_failed', - params: {'error': e.toString()}, - ), - ), + ToastService.error( + tr( + 'msg.userfront.profile.phone.send_failed', + params: {'error': e.toString()}, ), ); } @@ -303,24 +231,15 @@ class _ProfilePageState extends ConsumerState { _isVerifying = false; }); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.phone.verified'))), - ); - } - if (_editingField == 'phone') { - await _saveField(profile); + ToastService.success(tr('msg.userfront.profile.phone.verified')); } } catch (e) { setState(() => _isVerifying = false); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.profile.phone.verify_failed', - params: {'error': e.toString()}, - ), - ), + ToastService.error( + tr( + 'msg.userfront.profile.phone.verify_failed', + params: {'error': e.toString()}, ), ); } @@ -372,8 +291,9 @@ class _ProfilePageState extends ConsumerState { _newPasswordController?.clear(); _confirmPasswordController?.clear(); setState(() { - _passwordSuccess = tr('msg.userfront.profile.password.changed'); + _passwordSuccess = null; }); + ToastService.success(tr('msg.userfront.profile.password.changed')); } catch (e) { final message = e.toString().replaceFirst('Exception: ', ''); setState(() { @@ -382,6 +302,12 @@ class _ProfilePageState extends ConsumerState { params: {'error': message}, ); }); + ToastService.error( + tr( + 'msg.userfront.profile.password.change_failed', + params: {'error': message}, + ), + ); } finally { if (mounted) { setState(() => _isPasswordSaving = false); @@ -389,64 +315,6 @@ class _ProfilePageState extends ConsumerState { } } - void _autoSaveIfEditing(UserProfile profile, String field) { - if (_editingField != field) return; - if (_skipAutoSaveField == field) { - _debugLog('autosave_skip', field: field, reason: 'skip_flag'); - _skipAutoSaveField = null; - return; - } - if (_isVerifying) { - _debugLog('autosave_skip', field: field, reason: 'verifying'); - return; - } - if (_isSavingField) { - _debugLog('autosave_skip', field: field, reason: 'saving_in_flight'); - return; - } - if (!_hasFieldChanged(profile, field)) { - _debugLog( - 'autosave_skip', - field: field, - reason: 'unchanged', - changed: false, - ); - setState(() { - if (field == 'phone') { - _resetPhoneState(); - } - _editingField = null; - if (field == 'name') { - _nameTouched = false; - } else if (field == 'department') { - _departmentTouched = false; - } - }); - return; - } - _debugLog('autosave_trigger', field: field, changed: true); - _saveField(profile); - } - - void _handlePhoneFocusChange(UserProfile profile) { - if (_editingField != 'phone') return; - if (_skipAutoSaveField == 'phone') { - _skipAutoSaveField = null; - return; - } - if (_isVerifying) return; - if (_isSavingField) return; - if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return; - if (!_hasFieldChanged(profile, 'phone')) { - setState(() { - _resetPhoneState(); - _editingField = null; - }); - return; - } - _saveField(profile); - } - bool _hasFieldChanged(UserProfile profile, String field) { if (field == 'name') { return (_nameController?.text.trim() ?? '') != profile.name; @@ -466,6 +334,11 @@ class _ProfilePageState extends ConsumerState { _debugLog('save_skip', reason: 'saving_in_flight'); return; } + + setState(() { + _fieldSaveError = null; + }); + final currentField = _editingField!; final nextName = currentField == 'name' @@ -482,26 +355,24 @@ class _ProfilePageState extends ConsumerState { if (currentField == 'name' && nextName.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_name'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.name_required'))), - ); + setState(() { + _fieldSaveError = tr('msg.userfront.profile.name_required'); + }); return; } if (currentField == 'department' && nextDepartment.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_department'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.profile.department_required')), - ), - ); + setState(() { + _fieldSaveError = tr('msg.userfront.profile.department_required'); + }); return; } if (currentField == 'phone') { if (nextPhone.isEmpty) { _debugLog('save_skip', field: currentField, reason: 'empty_phone'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.phone_required'))), - ); + setState(() { + _fieldSaveError = tr('msg.userfront.profile.phone_required'); + }); return; } if (_isPhoneChanged && !_isPhoneVerified) { @@ -510,11 +381,9 @@ class _ProfilePageState extends ConsumerState { field: currentField, reason: 'phone_not_verified', ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr('msg.userfront.profile.phone_verify_required')), - ), - ); + setState(() { + _fieldSaveError = tr('msg.userfront.profile.phone_verify_required'); + }); return; } } @@ -531,13 +400,14 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } _editingField = null; - _nameTouched = false; - _departmentTouched = false; }); return; } - _isSavingField = true; + setState(() { + _isSavingField = true; + }); + _debugLog('save_dispatch', field: currentField, changed: true); try { @@ -555,30 +425,26 @@ class _ProfilePageState extends ConsumerState { _resetPhoneState(); } _editingField = null; - _nameTouched = false; - _departmentTouched = false; }); _debugLog('save_success', field: currentField); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(tr('msg.userfront.profile.update_success'))), - ); + ToastService.success(tr('msg.userfront.profile.update_success')); } } catch (e) { _debugLog('save_failed', field: currentField, reason: e.toString()); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - tr( - 'msg.userfront.profile.update_failed', - params: {'error': e.toString()}, - ), - ), - ), - ); + setState(() { + _fieldSaveError = tr( + 'msg.userfront.profile.update_failed', + params: {'error': e.toString().replaceFirst('Exception: ', '')}, + ); + }); } } finally { - _isSavingField = false; + if (mounted) { + setState(() { + _isSavingField = false; + }); + } } } @@ -793,13 +659,15 @@ class _ProfilePageState extends ConsumerState { ); } + final hasChanged = _hasFieldChanged(profile, field); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), Row( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( @@ -807,23 +675,40 @@ class _ProfilePageState extends ConsumerState { controller: controller, focusNode: field == 'name' ? _nameFocus : _departmentFocus, textInputAction: TextInputAction.done, - onSubmitted: (_) => _autoSaveIfEditing(profile, field), + onSubmitted: (_) => _saveField(profile), + onChanged: (_) { + setState(() { + _fieldSaveError = null; + }); + }, decoration: InputDecoration( border: const OutlineInputBorder(), hintText: label, + errorText: _fieldSaveError, ), ), ), const SizedBox(width: 12), - Listener( - onPointerDown: (_) { - _skipAutoSaveField = field; - }, - child: OutlinedButton( - key: Key('profile-$field-cancel-button'), - onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: Text(tr('ui.common.cancel')), - ), + ElevatedButton( + key: Key('profile-$field-save-button'), + onPressed: isUpdating || !hasChanged || _isSavingField + ? null + : () => _saveField(profile), + child: _isSavingField + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(tr('ui.common.save')), + ), + const SizedBox(width: 8), + OutlinedButton( + key: Key('profile-$field-cancel-button'), + onPressed: isUpdating || _isSavingField + ? null + : () => _cancelEditing(profile), + child: Text(tr('ui.common.cancel')), ), ], ), @@ -847,6 +732,9 @@ class _ProfilePageState extends ConsumerState { ); } + final hasChanged = _hasFieldChanged(profile, 'phone'); + final canSave = hasChanged && (!_isPhoneChanged || _isPhoneVerified); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -856,7 +744,7 @@ class _ProfilePageState extends ConsumerState { ), const SizedBox(height: 8), Row( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( @@ -864,10 +752,16 @@ class _ProfilePageState extends ConsumerState { focusNode: _phoneFocus, keyboardType: TextInputType.phone, textInputAction: TextInputAction.done, - onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'), + onSubmitted: (_) => _saveField(profile), + onChanged: (_) { + setState(() { + _fieldSaveError = null; + }); + }, decoration: InputDecoration( border: const OutlineInputBorder(), hintText: '01012345678', + errorText: _fieldSaveError, suffixIcon: _isPhoneVerified ? const Icon(Icons.check_circle, color: Colors.green) : null, @@ -886,14 +780,24 @@ class _ProfilePageState extends ConsumerState { ), ), const SizedBox(width: 8), - Listener( - onPointerDown: (_) { - _skipAutoSaveField = 'phone'; - }, - child: OutlinedButton( - onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: Text(tr('ui.common.cancel')), - ), + ElevatedButton( + onPressed: isUpdating || !canSave || _isSavingField + ? null + : () => _saveField(profile), + child: _isSavingField + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(tr('ui.common.save')), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: isUpdating || _isSavingField + ? null + : () => _cancelEditing(profile), + child: Text(tr('ui.common.cancel')), ), ], ), diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 89dd0a20..8fbabee1 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -28,6 +28,7 @@ import 'core/i18n/locale_gate.dart'; import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_utils.dart'; import 'core/i18n/toml_asset_loader.dart'; +import 'core/ui/toast_service.dart'; import 'package:logging/logging.dart'; import 'features/auth/presentation/consent_screen.dart'; import 'i18n.dart'; @@ -370,6 +371,11 @@ class BaronSSOApp extends StatelessWidget { localizationsDelegates: delegates, supportedLocales: supportedLocales, locale: locale, + builder: (context, child) { + return Stack( + children: [if (child != null) child, const ToastViewport()], + ); + }, theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base diff --git a/userfront/test/dashboard_session_time_resolver_test.dart b/userfront/test/dashboard_session_time_resolver_test.dart new file mode 100644 index 00000000..f3b72384 --- /dev/null +++ b/userfront/test/dashboard_session_time_resolver_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/dashboard/domain/session_time_resolver.dart'; +import 'package:userfront/features/profile/data/models/user_profile_model.dart'; + +void main() { + test('JWT에 iat가 있으면 세션 시각으로 사용한다', () { + const token = 'eyJhbGciOiJub25lIn0.eyJpYXQiOjE3MTEyMDc4MDB9.signature'; + + final issuedAt = resolveDashboardSessionIssuedAt(token: token); + + expect(issuedAt, isNotNull); + expect(issuedAt!.toUtc().toIso8601String(), '2024-03-23T15:30:00.000Z'); + }); + + test('cookie mode에서는 profile의 sessionAuthenticatedAt으로 복원한다', () { + final profile = UserProfile( + id: 'user-1', + email: 'qa@example.com', + name: 'QA User', + phone: '01012345678', + department: 'Platform', + affiliationType: 'GENERAL', + companyCode: '', + sessionAuthenticatedAt: '2026-03-23T15:30:00Z', + ); + + final issuedAt = resolveDashboardSessionIssuedAt( + token: null, + profile: profile, + ); + + expect(issuedAt, isNotNull); + expect(issuedAt!.toUtc().toIso8601String(), '2026-03-23T15:30:00.000Z'); + }); +} diff --git a/userfront/test/english_locale_placeholder_test.dart b/userfront/test/english_locale_placeholder_test.dart new file mode 100644 index 00000000..059329e2 --- /dev/null +++ b/userfront/test/english_locale_placeholder_test.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:toml/toml.dart'; + +const Set _placeholderValues = { + 'Action', + 'Action Label', + 'Approve Error', + 'Approve Success', + 'Body', + 'Code Hint', + 'Code Label', + 'Confirm', + 'Confirm Button', + 'Description', + 'Error', + 'Heading', + 'Input Label', + 'Invalid', + 'Label', + 'Load Failed', + 'Page Title', + 'Request Code', + 'Result Failure', + 'Sent', + 'Subtitle', + 'Title', + 'Update Success', +}; + +String? _readTomlValue(Map root, String key) { + dynamic cursor = root; + for (final part in key.split('.')) { + if (cursor is! Map) { + return null; + } + cursor = cursor[part]; + } + return cursor is String ? cursor : null; +} + +void main() { + test('critical english copy does not expose placeholder values', () { + final file = File('assets/translations/en.toml'); + final document = TomlDocument.parse(file.readAsStringSync()); + final translations = document.toMap(); + + const criticalKeys = [ + 'ui.userfront.forgot.heading', + 'ui.userfront.forgot.input_label', + 'ui.userfront.forgot.title', + 'msg.userfront.forgot.description', + 'msg.userfront.forgot.sent', + 'ui.userfront.login.link.action_label', + 'ui.userfront.login.link.page_title', + 'ui.userfront.login.link.title', + 'ui.userfront.login.unregistered.action', + 'ui.userfront.login.unregistered.title', + 'ui.userfront.login.verification.action_label', + 'ui.userfront.login.verification.page_title', + 'ui.userfront.login.verification.title', + 'msg.userfront.login.qr.load_failed', + 'msg.userfront.login.short_code.invalid', + 'msg.userfront.login.unregistered.body', + 'ui.userfront.login_success.title', + 'msg.userfront.login_success.subtitle', + 'msg.userfront.profile.load_failed', + 'msg.userfront.profile.update_success', + 'ui.userfront.profile.password.title', + 'ui.userfront.profile.phone.code_hint', + 'msg.userfront.reset.invalid_body', + 'msg.userfront.reset.invalid_title', + 'ui.userfront.reset.subtitle', + 'ui.userfront.reset.title', + 'ui.userfront.signup.title', + 'msg.userfront.signup.agreement.title', + 'msg.userfront.signup.auth.title', + 'ui.userfront.signup.auth.email.label', + 'ui.userfront.signup.auth.email.title', + 'msg.userfront.signup.password.title', + 'msg.userfront.signup.profile.title', + 'msg.userfront.signup.success.body', + 'msg.userfront.signup.success.title', + 'ui.userfront.signup.success.action', + ]; + + final failures = []; + for (final key in criticalKeys) { + final value = _readTomlValue(translations, key); + if (value == null || value.trim().isEmpty) { + failures.add('$key is missing'); + continue; + } + if (_placeholderValues.contains(value.trim())) { + failures.add('$key uses placeholder "$value"'); + } + } + + expect( + failures, + isEmpty, + reason: failures.isEmpty ? null : failures.join('\n'), + ); + }); +} diff --git a/userfront/test/profile_page_edit_flow_test.dart b/userfront/test/profile_page_edit_flow_test.dart new file mode 100644 index 00000000..8ba7025b --- /dev/null +++ b/userfront/test/profile_page_edit_flow_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/profile/data/models/user_profile_model.dart'; +import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart'; +import 'package:userfront/features/profile/presentation/pages/profile_page.dart'; + +// Mocking the profile notifier +class MockProfileNotifier extends ProfileNotifier { + UserProfile? _profile; + bool updateCalled = false; + String? updatedName; + + @override + Future build() async { + _profile = UserProfile( + id: 'test-id', + email: 'test@example.com', + name: 'Original Name', + phone: '01012345678', + department: 'Dev', + affiliationType: 'employee', + companyCode: 'C100', + ); + return _profile; + } + + @override + Future loadProfile() async { + state = const AsyncValue.loading(); + state = AsyncValue.data(_profile); + return _profile; + } + + @override + Future updateProfile({ + String? name, + String? phone, + String? department, + }) async { + updateCalled = true; + updatedName = name; + _profile = _profile!.copyWith( + name: name ?? _profile!.name, + phone: phone ?? _profile!.phone, + department: department ?? _profile!.department, + ); + state = AsyncValue.data(_profile); + } +} + +void main() { + testWidgets( + 'ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)', + (tester) async { + final recordedErrors = []; + final previousOnError = FlutterError.onError; + FlutterError.onError = (details) { + final text = details.exceptionAsString(); + if (text.contains('A RenderFlex overflowed')) { + return; + } + recordedErrors.add(details); + }; + addTearDown(() { + FlutterError.onError = previousOnError; + }); + + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final mockNotifier = MockProfileNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [profileProvider.overrideWith(() => mockNotifier)], + child: const MaterialApp(home: Scaffold(body: ProfilePage())), + ), + ); + + await tester.pumpAndSettle(); + + // 1. Entering edit mode + final editButton = find.byKey(const Key('profile-name-edit-button')); + expect(editButton, findsOneWidget); + await tester.tap(editButton); + await tester.pumpAndSettle(); + + final inputField = find.byKey(const Key('profile-name-input')); + expect(inputField, findsOneWidget); + + // 2. Testing cancel flow + await tester.enterText(inputField, 'Changed Name'); + await tester.pumpAndSettle(); + + final cancelButton = find.byKey(const Key('profile-name-cancel-button')); + await tester.tap(cancelButton); + await tester.pumpAndSettle(); + + // After cancellation, the field should be read-only again. + expect(find.byKey(const Key('profile-name-input')), findsNothing); + // Find text could be part of ListTile + expect(find.text('Original Name'), findsWidgets); + + // 3. Re-enter edit mode and explicitly save + await tester.tap(find.byKey(const Key('profile-name-edit-button'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('profile-name-input')), + 'Saved Name', + ); + await tester.pumpAndSettle(); + + final saveButton = find.byKey(const Key('profile-name-save-button')); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + + FlutterError.onError = previousOnError; + + // Verify the mock received the update + expect(mockNotifier.updateCalled, isTrue); + expect(mockNotifier.updatedName, 'Saved Name'); + expect(recordedErrors, isEmpty); + }, + ); +}