diff --git a/Makefile b/Makefile index 855f72ef..f08f8d6a 100644 --- a/Makefile +++ b/Makefile @@ -107,12 +107,17 @@ logs-app: docker compose -f $(COMPOSE_APP) logs -f # --- 로컬 통합 코드 체크 --- +PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright +PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE +PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE +PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE + ifeq ($(CI),) -PLAYWRIGHT_INSTALL_ALL := npx playwright install -PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install chromium +PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi' +PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi' else -PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps -PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium +PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install --with-deps; fi' +PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install --with-deps chromium; fi' endif .PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests diff --git a/adminfront/package.json b/adminfront/package.json index fbcba5b6..80dd2bb4 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -7,7 +7,7 @@ "node": ">=24.0.0" }, "scripts": { - "dev": "vite --host 0.0.0.0", + "dev": "vite --host 127.0.0.1", "build": "tsc -b && vite build", "lint": "biome check .", "lint:fix": "biome check . --write", diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 62a5e680..f82e6ea7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -19,7 +19,10 @@ import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { fetchMe } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; -import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding"; +import { + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../lib/sessionSliding"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; @@ -221,6 +224,52 @@ function AppLayout() { isSessionExpiryEnabled, ]); + useEffect(() => { + const maybeKeepSessionAlive = async () => { + const now = Date.now(); + if ( + !shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: auth.user?.expires_at, + nowMs: now, + isEnabled: isSessionExpiryEnabled, + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + isRenewInFlight: isRenewInFlightRef.current, + lastAttemptAtMs: lastRenewAttemptAtRef.current, + }) + ) { + return; + } + + isRenewInFlightRef.current = true; + lastRenewAttemptAtRef.current = now; + + try { + await auth.signinSilent(); + } catch (error) { + console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + } finally { + isRenewInFlightRef.current = false; + } + }; + + const timer = window.setInterval(() => { + void maybeKeepSessionAlive(); + }, 30_000); + + void maybeKeepSessionAlive(); + + return () => { + window.clearInterval(timer); + }; + }, [ + auth, + auth.isAuthenticated, + auth.isLoading, + auth.user?.expires_at, + isSessionExpiryEnabled, + ]); + useEffect(() => { const routeKey = `${location.pathname}${location.search}${location.hash}`; if (lastVisitedRouteRef.current === null) { diff --git a/adminfront/src/features/auth/AuthCallbackPage.tsx b/adminfront/src/features/auth/AuthCallbackPage.tsx index ed8889d8..ed1e0630 100644 --- a/adminfront/src/features/auth/AuthCallbackPage.tsx +++ b/adminfront/src/features/auth/AuthCallbackPage.tsx @@ -14,7 +14,14 @@ function AuthCallbackPage() { if (user?.access_token) { window.localStorage.setItem("admin_session", user.access_token); } - navigate("/", { replace: true }); + const returnTo = + typeof auth.user?.state === "object" && + auth.user?.state !== null && + "returnTo" in auth.user.state && + typeof auth.user.state.returnTo === "string" + ? auth.user.state.returnTo + : "/"; + navigate(returnTo, { replace: true }); } else if (auth.error) { console.error("Auth Error:", auth.error); navigate("/login", { replace: true }); diff --git a/adminfront/src/features/auth/LoginPage.tsx b/adminfront/src/features/auth/LoginPage.tsx index 07da4600..bc6d780d 100644 --- a/adminfront/src/features/auth/LoginPage.tsx +++ b/adminfront/src/features/auth/LoginPage.tsx @@ -1,5 +1,7 @@ import { ExternalLink, LogIn, ShieldHalf } from "lucide-react"; +import { useEffect, useRef } from "react"; import { useAuth } from "react-oidc-context"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { Button } from "../../components/ui/button"; import { Card, @@ -11,10 +13,40 @@ import { function LoginPage() { const auth = useAuth(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const autoStartedRef = useRef(false); + const returnTo = searchParams.get("returnTo") || "/"; + const shouldAutoLogin = searchParams.get("auto") === "1"; + + useEffect(() => { + if (auth.isAuthenticated) { + navigate(returnTo, { replace: true }); + } + }, [auth.isAuthenticated, navigate, returnTo]); + + useEffect(() => { + if (!shouldAutoLogin) { + return; + } + if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) { + return; + } + + autoStartedRef.current = true; + void auth.signinRedirect({ + state: { + returnTo, + }, + }); + }, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]); const handleSSOLogin = () => { - // OIDC client-side authentication flow started here - auth.signinRedirect(); + void auth.signinRedirect({ + state: { + returnTo: "/", + }, + }); }; return ( diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts index 8f46d964..aab02a2b 100644 --- a/adminfront/src/lib/auth.ts +++ b/adminfront/src/lib/auth.ts @@ -10,7 +10,7 @@ export const oidcConfig: AuthProviderProps = { scope: "openid offline_access profile email", // offline_access for refresh token post_logout_redirect_uri: window.location.origin, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: true, + automaticSilentRenew: false, }; export const userManager = new UserManager({ diff --git a/adminfront/src/lib/sessionSliding.test.ts b/adminfront/src/lib/sessionSliding.test.ts index 410ac63e..cce36661 100644 --- a/adminfront/src/lib/sessionSliding.test.ts +++ b/adminfront/src/lib/sessionSliding.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SESSION_RENEW_THRESHOLD_MS, shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, } from "./sessionSliding"; describe("shouldAttemptSlidingSessionRenew", () => { @@ -71,3 +72,55 @@ describe("shouldAttemptSlidingSessionRenew", () => { ).toBe(false); }); }); + +describe("shouldAttemptUnlimitedSessionRenew", () => { + const nowMs = 1_700_000_000_000; + + it("returns false when unlimited mode is not active", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000, + ), + nowMs, + isEnabled: true, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(false); + }); + + it("returns true near expiry when session expiry management is disabled", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000, + ), + nowMs, + isEnabled: false, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(true); + }); + + it("returns false when the token still has enough remaining lifetime", () => { + expect( + shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: Math.floor( + (nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000, + ), + nowMs, + isEnabled: false, + isAuthenticated: true, + isLoading: false, + isRenewInFlight: false, + lastAttemptAtMs: 0, + }), + ).toBe(false); + }); +}); diff --git a/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts index 7096e7f3..be152778 100644 --- a/adminfront/src/lib/sessionSliding.ts +++ b/adminfront/src/lib/sessionSliding.ts @@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({ return true; } + +export function shouldAttemptUnlimitedSessionRenew({ + expiresAtSec, + nowMs, + isEnabled, + isAuthenticated, + isLoading, + isRenewInFlight, + lastAttemptAtMs, + thresholdMs = SESSION_RENEW_THRESHOLD_MS, + throttleMs = SESSION_RENEW_THROTTLE_MS, +}: SlidingSessionRenewDecisionParams) { + if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { + return false; + } + + if (typeof expiresAtSec !== "number") { + return false; + } + + const remainingMs = expiresAtSec * 1000 - nowMs; + if (remainingMs <= 0 || remainingMs > thresholdMs) { + return false; + } + + if (nowMs - lastAttemptAtMs < throttleMs) { + return false; + } + + return true; +} diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 868d4f8d..eaacc511 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1501,6 +1501,7 @@ ory = "" session = "" [ui.userfront.dashboard] +link_status_label = "" last_auth_label = "" status_history = "" diff --git a/adminfront/vite.config.ts b/adminfront/vite.config.ts index 2a8338a8..8176399c 100644 --- a/adminfront/vite.config.ts +++ b/adminfront/vite.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ plugins: [react()], envPrefix: ["VITE_", "USERFRONT_"], server: { - host: "0.0.0.0", + host: "127.0.0.1", allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], proxy: { "/api": { @@ -15,7 +15,7 @@ export default defineConfig({ }, }, preview: { - host: "0.0.0.0", + host: "127.0.0.1", port: 5173, allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], proxy: { diff --git a/backend/cmd/server/headless_login_e2e_test.go b/backend/cmd/server/headless_login_e2e_test.go index f91a5b53..89a1822b 100644 --- a/backend/cmd/server/headless_login_e2e_test.go +++ b/backend/cmd/server/headless_login_e2e_test.go @@ -121,6 +121,18 @@ func (m *e2eMockKratosAdminService) DeleteIdentity(ctx context.Context, identity return nil } +func (m *e2eMockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + return nil, nil +} + +func (m *e2eMockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + return nil, nil +} + +func (m *e2eMockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + return nil +} + func newHeadlessLoginE2EApp(h *authhandler.AuthHandler, appEnv string) *fiber.App { app := fiber.New(fiber.Config{ DisableStartupMessage: true, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e8376710..f7db4c9d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -582,6 +582,8 @@ func main() { user.Post("/me/password", authHandler.ChangeMyPassword) user.Post("/me/send-code", authHandler.SendUpdateCode) user.Post("/me/verify-code", authHandler.VerifyUpdateCode) + user.Get("/sessions", authHandler.ListMySessions) + user.Delete("/sessions/:id", authHandler.DeleteMySession) user.Get("/rp/linked", authHandler.ListLinkedRps) user.Get("/rp/history", authHandler.ListRpHistory) user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 23ddf61a..2e15147a 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -920,6 +920,15 @@ func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string { if baseURL == "" { return strings.TrimRight(envURL, "/") } + + envParsed, envErr := url.Parse(strings.TrimRight(envURL, "/")) + baseParsed, baseErr := url.Parse(strings.TrimRight(baseURL, "/")) + if envErr == nil && baseErr == nil && + strings.EqualFold(envParsed.Hostname(), baseParsed.Hostname()) && + envParsed.Scheme == "https" && baseParsed.Scheme == "http" { + return strings.TrimRight(envURL, "/") + } + return baseURL } @@ -1081,6 +1090,18 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[strin return claims } +func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string]any { + if claims == nil { + claims = map[string]any{} + } + sessionID = strings.TrimSpace(sessionID) + if sessionID != "" { + claims["session_id"] = sessionID + claims["sid"] = sessionID + } + return claims +} + func collectEmailList(traits map[string]any, primaryEmail string) []string { emails := make([]string, 0) seen := make(map[string]struct{}) @@ -2493,6 +2514,8 @@ func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error { return errorJSONCode(c, status, code, message) } + c.Locals("user_id", authInfo.Subject) + c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject) @@ -2799,7 +2822,15 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) + c.Locals("user_id", authInfo.Subject) + c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) + if req.LoginChallenge == "" { + attachAuditClientDetails(c, domain.HydraClient{ + ClientID: "userfront", + ClientName: "UserFront", + }) + } ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) // --- OIDC 로그인 흐름 처리 --- @@ -2808,11 +2839,14 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) - if err == nil && loginReq != nil && loginReq.Client.Metadata != nil { - if status, ok := loginReq.Client.Metadata["status"].(string); ok { - if strings.ToLower(status) == "inactive" { - slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) - return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") + if err == nil && loginReq != nil { + attachAuditClientDetails(c, loginReq.Client) + if loginReq.Client.Metadata != nil { + if status, ok := loginReq.Client.Metadata["status"].(string); ok { + if strings.ToLower(status) == "inactive" { + slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) + return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") + } } } } @@ -2845,6 +2879,27 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { return c.JSON(resp) } +func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) { + if c == nil { + return + } + + clientID := strings.TrimSpace(client.ClientID) + if clientID == "" { + return + } + + clientName := strings.TrimSpace(client.ClientName) + if clientName == "" { + clientName = clientID + } + + c.Locals("audit_details_extra", map[string]any{ + "client_id": clientID, + "client_name": clientName, + }) +} + // InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 이메일 또는 SMS를 보냅니다. func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { startTime := time.Now() @@ -4075,18 +4130,7 @@ func extractClientIPFromHeaders(c *fiber.Ctx) string { if c == nil { return "" } - if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } - } - } - if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { - return realIP - } - return c.IP() + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) } type authTimelineItem struct { @@ -4290,11 +4334,10 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { continue } consent, ok := consentMap[clientID] - if !ok { - continue - } - if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) { - continue + if ok { + if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) { + continue + } } oathkeeperLogs = append(oathkeeperLogs, log) if len(oathkeeperLogs) >= fetchLimit { @@ -4343,36 +4386,75 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { return info, true } + clientCache := make(map[string]loginClientInfo) + resolveClientByID := func(cid string) (loginClientInfo, bool) { + cid = strings.TrimSpace(cid) + if cid == "" || h.Hydra == nil { + return loginClientInfo{}, false + } + if cached, ok := clientCache[cid]; ok { + return cached, cached.ClientID != "" + } + client, err := h.Hydra.GetClient(c.Context(), cid) + if err != nil || client == nil { + clientCache[cid] = loginClientInfo{} + return loginClientInfo{}, false + } + name := strings.TrimSpace(client.ClientName) + if name == "" { + name = cid + } + info := loginClientInfo{ + ClientID: cid, + Name: name, + } + clientCache[cid] = info + return info, true + } + items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs)) for i := range authLogs { log := authLogs[i] appName := "Baron 로그인" clientID := "" path := strings.ToLower(extractAuditPath(log)) - if strings.Contains(path, "/api/v1/auth/oidc/login/accept") { - appName = "OIDC 로그인" - // 우선 audit details의 client 정보를 사용하고, 없으면 Hydra 조회로 보강 - if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil { - if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" { - appName = strings.TrimSpace(name) - } - if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" { - clientID = strings.TrimSpace(cid) - if appName == "OIDC 로그인" { - appName = clientID - } - } + + isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept") + isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") + + // 우선 audit details의 client 정보를 사용 + if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil { + if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" { + clientID = strings.TrimSpace(cid) } - if appName == "OIDC 로그인" { - loginChallenge := extractLoginChallengeFromAuditDetails(log.Details) - if loginChallenge != "" { - if info, ok := resolveLoginClient(loginChallenge); ok { - appName = info.Name - clientID = info.ClientID - } + if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" { + appName = strings.TrimSpace(name) + } + } + + // 기본값이거나 클라이언트 ID인 경우 Hydra 조회로 보강 + if appName == "Baron 로그인" || appName == "" { + if isOidcAccept { + appName = "OIDC 로그인" + } + if clientID != "" { + appName = clientID + if info, ok := resolveClientByID(clientID); ok { + appName = info.Name } } } + + if (isOidcAccept || isPasswordLogin) && (appName == "OIDC 로그인" || appName == "Baron 로그인" || appName == clientID) { + loginChallenge := extractLoginChallengeFromAuditDetails(log.Details) + if loginChallenge != "" { + if info, ok := resolveLoginClient(loginChallenge); ok { + appName = info.Name + clientID = info.ClientID + } + } + } + item := authTimelineItem{ EventID: log.EventID, Timestamp: log.Timestamp, @@ -4397,11 +4479,17 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { if clientID == "" { continue } - consent := consentMap[clientID] - appName := consent.Name - if appName == "" { - appName = clientID + + appName := clientID + if consent, ok := consentMap[clientID]; ok { + appName = consent.Name } + if appName == "" || appName == clientID { + if info, ok := resolveClientByID(clientID); ok { + appName = info.Name + } + } + details := map[string]any{ "path": log.Path, "client_id": clientID, @@ -4418,6 +4506,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { EventID: eventID, Timestamp: log.Timestamp, UserID: profile.ID, + SessionID: extractSessionIDFromOathkeeperLog(log), EventType: fmt.Sprintf("%s %s", log.Method, log.Path), Status: status, AuthMethod: "세션 위임", @@ -4482,7 +4571,8 @@ type linkedRpSummary struct { ID string `json:"id"` Name string `json:"name"` Logo string `json:"logo,omitempty"` - URL string `json:"url,omitempty"` // Added + URL string `json:"url,omitempty"` + InitURL string `json:"init_url,omitempty"` LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"` Status string `json:"status"` Scopes []string `json:"scopes,omitempty"` @@ -4563,17 +4653,19 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" { scopes = strings.Fields(client.Scope) } + initURL := resolveLinkedRPInitURL(client.ClientID, scopes, client.RedirectURIs) existing := records[clientID] if existing == nil { records[clientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ - ID: clientID, - Name: name, - Logo: extractHydraClientLogo(client.Metadata), - URL: clientURL, - Status: "active", // Hydra 세션이 있으면 활성 - Scopes: scopes, + ID: clientID, + Name: name, + Logo: extractHydraClientLogo(client.Metadata), + URL: clientURL, + InitURL: initURL, + Status: "active", // Hydra 세션이 있으면 활성 + Scopes: scopes, }, lastAuth: lastAuth, } @@ -4589,12 +4681,57 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { if existing.URL == "" { existing.URL = clientURL } + if existing.InitURL == "" { + existing.InitURL = initURL + } existing.Scopes = mergeScopes(existing.Scopes, scopes) if lastAuth.After(existing.lastAuth) { existing.lastAuth = lastAuth } } + // Consent session payload may omit metadata fields such as logo_url. + // Rehydrate missing display fields from the full Hydra client object. + for clientID, record := range records { + if record == nil { + continue + } + needsHydraLookup := record.Logo == "" || record.URL == "" || record.InitURL == "" + if !needsHydraLookup { + continue + } + + client, err := h.Hydra.GetClient(c.Context(), clientID) + if err != nil { + continue + } + + if record.Name == "" { + name := strings.TrimSpace(client.ClientName) + if name == "" { + name = client.ClientID + } + record.Name = name + } + if record.Logo == "" { + record.Logo = extractHydraClientLogo(client.Metadata) + } + if record.URL == "" { + record.URL = resolveLinkedRPURL( + client.ClientID, + client.ClientURI, + client.RedirectURIs, + ) + } + if record.InitURL == "" { + record.InitURL = resolveLinkedRPInitURL( + client.ClientID, + record.Scopes, + client.RedirectURIs, + ) + } + } + // [New] DB에서 과거 동의 내역 가져와 병합 (비활성 RP 포함) if h.ConsentRepo != nil { for _, subject := range subjects { @@ -4643,15 +4780,21 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { client.ClientURI, client.RedirectURIs, ) + initURL := resolveLinkedRPInitURL( + client.ClientID, + dc.GrantedScopes, + client.RedirectURIs, + ) records[dc.ClientID] = &linkedRpRecord{ linkedRpSummary: linkedRpSummary{ - ID: dc.ClientID, - Name: name, - Logo: extractHydraClientLogo(client.Metadata), - URL: clientURL, - Status: status, - Scopes: dc.GrantedScopes, + ID: dc.ClientID, + Name: name, + Logo: extractHydraClientLogo(client.Metadata), + URL: clientURL, + InitURL: initURL, + Status: status, + Scopes: dc.GrantedScopes, }, lastAuth: dc.UpdatedAt, } @@ -4725,6 +4868,11 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { } } record.URL = clientURL + record.InitURL = resolveLinkedRPInitURL( + client.ClientID, + scopes, + client.RedirectURIs, + ) } else { // Hydra 정보 없음 (삭제됨 등) -> Audit 정보나 ID로 대체 if record.Name == "" { @@ -4834,7 +4982,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject) // 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행 } else { - sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) + sessionClaims := withOidcSessionMetadata( + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + h.resolveCurrentSessionID(c), + ) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err != nil { slog.Error("failed to auto-accept hydra consent request", "error", err) @@ -4954,7 +5105,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { if loginID := pickLoginIDFromTraits(identity.Traits); loginID != "" { c.Locals("login_id", loginID) } - sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) + currentSessionID := h.resolveCurrentSessionID(c) + sessionClaims := withOidcSessionMetadata( + buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope), + currentSessionID, + ) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims) if err != nil { @@ -4981,12 +5136,17 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { "scopes": consentRequest.RequestedScope, "client_name": consentRequest.Client.ClientName, } + if currentSessionID != "" { + detailsMap["session_id"] = currentSessionID + detailsMap["approved_session_id"] = currentSessionID + } detailsBytes, _ := json.Marshal(detailsMap) _ = h.AuditRepo.Create(&domain.AuditLog{ EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, + SessionID: currentSessionID, EventType: "consent.granted", Status: "success", IPAddress: c.IP(), @@ -5038,18 +5198,7 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) if err == nil && loginReq != nil { - // Audit 상세 정보 보강: OIDC 로그인 시점에 client 정보를 저장 - clientID := strings.TrimSpace(loginReq.Client.ClientID) - if clientID != "" { - clientName := strings.TrimSpace(loginReq.Client.ClientName) - if clientName == "" { - clientName = clientID - } - c.Locals("audit_details_extra", map[string]any{ - "client_id": clientID, - "client_name": clientName, - }) - } + attachAuditClientDetails(c, loginReq.Client) if loginReq.Client.Metadata != nil { if status, ok := loginReq.Client.Metadata["status"].(string); ok { @@ -5129,12 +5278,12 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe // 1. Try to fetch real profile if token/cookie exists if token != "" || cookie != "" { // Try Redis Cache - if h.RedisService != nil && token != "" { - cacheKey = "cache:profile:token:" + token + if h.RedisService != nil && token == "" && cookie != "" { + cacheKey = "cache:profile:cookie:" + cookie cached, _ := h.RedisService.Get(cacheKey) if cached != "" { if json.Unmarshal([]byte(cached), &profile) == nil { - slog.Debug("Profile loaded from cache", "token", token[:10]+"...", "role", profile.Role) + slog.Debug("Profile loaded from cache", "role", profile.Role) } } } @@ -5232,7 +5381,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe // IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key // or we should include the mock role in the cache key. // For simplicity, let's skip caching if mockRole is present in dev. - if h.RedisService != nil && cacheKey != "" && err == nil && !(isDev && mockRole != "") { + if h.RedisService != nil && token == "" && cacheKey != "" && err == nil && !(isDev && mockRole != "") { if data, err := json.Marshal(profile); err == nil { ttlStr := os.Getenv("PROFILE_CACHE_TTL") ttl := 30 * time.Minute // Default TTL @@ -5747,6 +5896,16 @@ func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { return parseClientIDFromRaw(log.Raw) } +func extractSessionIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string { + if value := parseSessionIDFromURL(log.Target); value != "" { + return value + } + if value := parseSessionIDFromURL(log.Path); value != "" { + return value + } + return parseSessionIDFromRaw(log.Raw) +} + func parseClientIDFromURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -5765,6 +5924,23 @@ func parseClientIDFromURL(raw string) string { return "" } +func parseSessionIDFromURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil { + return "" + } + for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} { + if id := strings.TrimSpace(parsed.Query().Get(key)); id != "" { + return id + } + } + return "" +} + func parseClientIDFromRaw(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -5816,15 +5992,7 @@ func extractSessionIDFromAuditDetails(details string) string { if err := json.Unmarshal([]byte(details), &payload); err != nil { return "" } - if raw, ok := payload["session_id"]; ok { - switch value := raw.(type) { - case string: - return value - default: - return fmt.Sprint(value) - } - } - return "" + return readSessionIDFromAny(payload) } func extractApprovedSessionIDFromAuditDetails(details string) string { @@ -5854,6 +6022,51 @@ func extractApprovedSessionIDFromAuditDetails(details string) string { return "" } +func parseSessionIDFromRaw(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return "" + } + return readSessionIDFromAny(payload) +} + +func readSessionIDFromAny(payload any) string { + switch value := payload.(type) { + case map[string]any: + for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} { + if raw, ok := value[key]; ok { + switch sid := raw.(type) { + case string: + if strings.TrimSpace(sid) != "" { + return strings.TrimSpace(sid) + } + default: + rendered := strings.TrimSpace(fmt.Sprint(sid)) + if rendered != "" && rendered != "" { + return rendered + } + } + } + } + for _, nested := range value { + if sid := readSessionIDFromAny(nested); sid != "" { + return sid + } + } + case []any: + for _, nested := range value { + if sid := readSessionIDFromAny(nested); sid != "" { + return sid + } + } + } + return "" +} + func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) { id, _, _, err := h.getKratosIdentity(token) return id, err @@ -6294,6 +6507,10 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai slog.Warn("Hydra token is not active") return nil, errors.New("token is not active") } + if err := h.validateHydraTokenSession(ctx, intro); err != nil { + slog.Warn("Hydra token session validation failed", "error", err) + return nil, err + } slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID) @@ -6715,6 +6932,63 @@ func resolveLinkedRPURL(clientID string, clientURI string, redirectURIs []string return "" } +func resolveLinkedRPInitURL(clientID string, scopes []string, redirectURIs []string) string { + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return "" + } + + switch clientID { + case "adminfront": + if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ADMINFRONT_URL")), "/"); value != "" { + return value + "/login?auto=1" + } + case "devfront": + if value := strings.TrimRight(strings.TrimSpace(os.Getenv("DEVFRONT_URL")), "/"); value != "" { + return value + "/login?auto=1&returnTo=%2Fclients" + } + } + + hydraPublicURL := strings.TrimRight(os.Getenv("HYDRA_PUBLIC_URL"), "/") + if hydraPublicURL == "" { + userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") + if userfrontURL == "" { + userfrontURL = "https://sso.hmac.kr" + } + hydraPublicURL = userfrontURL + "/oidc" + } + + redirectURI := "" + if len(redirectURIs) > 0 { + redirectURI = strings.TrimSpace(redirectURIs[0]) + } + + mergedScopes := make([]string, 0, len(scopes)+1) + seen := map[string]struct{}{} + for _, scope := range append([]string{"openid"}, scopes...) { + scope = strings.TrimSpace(scope) + if scope == "" { + continue + } + if _, ok := seen[scope]; ok { + continue + } + seen[scope] = struct{}{} + mergedScopes = append(mergedScopes, scope) + } + + params := url.Values{} + params.Set("client_id", clientID) + params.Set("response_type", "code") + params.Set("scope", strings.Join(mergedScopes, " ")) + params.Set("state", GenerateSecureAlnumToken(16)) + if redirectURI != "" { + params.Set("redirect_uri", redirectURI) + } + + return fmt.Sprintf("%s/oauth2/auth?%s", hydraPublicURL, params.Encode()) +} + func mergeScopes(current []string, next []string) []string { if len(next) == 0 { return current @@ -6750,6 +7024,173 @@ type rpHistoryItem struct { Status string `json:"status"` } +type userSessionItem struct { + SessionID string `json:"session_id"` + AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + IssuedAt *time.Time `json:"issued_at,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + ClientID string `json:"client_id,omitempty"` + AppName string `json:"app_name,omitempty"` + IsCurrent bool `json:"is_current"` + IsActive bool `json:"is_active"` +} + +type userSessionListResponse struct { + Items []userSessionItem `json:"items"` +} + +func (h *AuthHandler) ListMySessions(c *fiber.Ctx) error { + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") + } + + profile, err := h.resolveCurrentProfile(c) + if err != nil { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + if strings.TrimSpace(profile.ID) == "" { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + + sessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions") + } + + currentSessionID := h.resolveCurrentSessionID(c) + auditHints := h.loadSessionAuditHints(c.Context(), profile.ID) + + items := make([]userSessionItem, 0, len(sessions)) + for _, session := range sessions { + if !session.Active { + continue + } + item := userSessionItem{ + SessionID: session.ID, + IsCurrent: session.ID != "" && session.ID == currentSessionID, + IsActive: session.Active, + } + if !session.AuthenticatedAt.IsZero() { + ts := session.AuthenticatedAt + item.AuthenticatedAt = &ts + item.LastSeenAt = &ts + } + if !session.ExpiresAt.IsZero() { + ts := session.ExpiresAt + item.ExpiresAt = &ts + } + if !session.IssuedAt.IsZero() { + ts := session.IssuedAt + item.IssuedAt = &ts + if item.AuthenticatedAt == nil { + item.AuthenticatedAt = &ts + } + if item.LastSeenAt == nil { + item.LastSeenAt = &ts + } + } + if hint, ok := auditHints[session.ID]; ok { + if item.IPAddress == "" { + item.IPAddress = hint.IPAddress + } + if item.UserAgent == "" { + item.UserAgent = hint.UserAgent + } + if item.ClientID == "" { + item.ClientID = hint.ClientID + } + if item.AppName == "" { + item.AppName = hint.AppName + } + if hint.Timestamp != nil { + item.LastSeenAt = hint.Timestamp + } + } + if item.UserAgent == "" && len(session.Devices) > 0 { + deviceUserAgent := strings.TrimSpace(session.Devices[0].UserAgent) + if !looksLikeInternalUserAgent(deviceUserAgent) { + item.UserAgent = deviceUserAgent + } + } + if item.IPAddress == "" && len(session.Devices) > 0 { + item.IPAddress = strings.TrimSpace(session.Devices[0].IPAddress) + } + if item.IsCurrent { + applyCurrentSessionRequestHints(c, &item) + } + items = append(items, item) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].IsCurrent != items[j].IsCurrent { + return items[i].IsCurrent + } + iTime := latestSessionTimestamp(items[i]) + jTime := latestSessionTimestamp(items[j]) + if iTime.Equal(jTime) { + return items[i].SessionID < items[j].SessionID + } + return iTime.After(jTime) + }) + + return c.JSON(userSessionListResponse{Items: items}) +} + +func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error { + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") + } + + profile, err := h.resolveCurrentProfile(c) + if err != nil { + return errorJSON(c, fiber.StatusUnauthorized, "Invalid session") + } + targetSessionID := strings.TrimSpace(c.Params("id")) + if targetSessionID == "" { + return errorJSON(c, fiber.StatusBadRequest, "session id is required") + } + + mySessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions") + } + ownedSession := false + for _, candidate := range mySessions { + if strings.TrimSpace(candidate.ID) == targetSessionID { + ownedSession = true + break + } + } + if !ownedSession { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + + session, err := h.KratosAdmin.GetSession(c.Context(), targetSessionID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch session") + } + if session == nil { + h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, "already_missing") + return c.JSON(fiber.Map{"status": "ok"}) + } + + result := "revoked" + if !session.Active { + result = "already_inactive" + } else if err := h.KratosAdmin.DeleteSession(c.Context(), targetSessionID); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to delete session") + } + if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions") + } + + h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result) + return c.JSON(fiber.Map{"status": "ok"}) +} + func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error { subject, err := h.resolveConsentSubject(c) if err != nil || subject == "" { @@ -6837,3 +7278,366 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error { return c.JSON(fiber.Map{"items": items}) } + +type sessionAuditHint struct { + Timestamp *time.Time + IPAddress string + UserAgent string + ClientID string + AppName string +} + +func latestSessionTimestamp(item userSessionItem) time.Time { + for _, candidate := range []*time.Time{item.LastSeenAt, item.AuthenticatedAt, item.IssuedAt} { + if candidate != nil { + return *candidate + } + } + return time.Time{} +} + +func (h *AuthHandler) resolveCurrentSessionID(c *fiber.Ctx) string { + if c == nil { + return "" + } + if token := h.getBearerToken(c); token != "" { + if sessionID := extractSessionIDFromJWT(token); sessionID != "" { + return sessionID + } + if sessionID, err := h.getKratosSessionID(token); err == nil { + return sessionID + } + } + if cookie := c.Get("Cookie"); cookie != "" { + if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil { + return sessionID + } + } + return "" +} + +func applyCurrentSessionRequestHints(c *fiber.Ctx, item *userSessionItem) { + if c == nil || item == nil || !item.IsCurrent { + return + } + + if item.IPAddress == "" { + item.IPAddress = strings.TrimSpace(resolveRequestClientIP(c)) + } + if item.UserAgent == "" { + userAgent := strings.TrimSpace(c.Get("User-Agent")) + if !looksLikeInternalUserAgent(userAgent) { + item.UserAgent = userAgent + } + } + if strings.TrimSpace(item.ClientID) == "" { + item.ClientID = "userfront" + } + if strings.TrimSpace(item.AppName) == "" { + item.AppName = "UserFront" + } +} + +func resolveRequestClientIP(c *fiber.Ctx) string { + if c == nil { + return "" + } + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) +} + +func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint { + hints := make(map[string]sessionAuditHint) + if h.AuditRepo == nil || strings.TrimSpace(userID) == "" { + return hints + } + + logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{ + "login_success", + "qr_login_success", + "link_login_success", + "code_login_success", + "password_login_success", + "consent.granted", + "POST /api/v1/auth/oidc/login/accept", + "POST /api/v1/auth/password/login", + "POST /api/v1/auth/magic-link/verify", + "POST /api/v1/auth/login/code/verify", + "POST /api/v1/auth/qr/approve", + "session.revoked", + }, 200) + if err != nil { + return hints + } + + for _, log := range logs { + sessionID := strings.TrimSpace(log.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + continue + } + + ts := log.Timestamp + ipAddress := strings.TrimSpace(log.IPAddress) + userAgent := strings.TrimSpace(log.UserAgent) + clientID, appName := deriveSessionClientInfo(log) + if details, err := parseAuditDetails(log.Details); err == nil { + if approvedIP, ok := details["approved_ip"].(string); ok && strings.TrimSpace(approvedIP) != "" { + ipAddress = strings.TrimSpace(approvedIP) + } + if approvedUserAgent, ok := details["approved_user_agent"].(string); ok && strings.TrimSpace(approvedUserAgent) != "" { + userAgent = strings.TrimSpace(approvedUserAgent) + } + } + if looksLikeInternalUserAgent(userAgent) { + userAgent = "" + } + hints[sessionID] = mergeSessionAuditHint(hints[sessionID], sessionAuditHint{ + Timestamp: &ts, + IPAddress: ipAddress, + UserAgent: userAgent, + ClientID: clientID, + AppName: appName, + }) + } + return hints +} + +func mergeSessionAuditHint(existing sessionAuditHint, candidate sessionAuditHint) sessionAuditHint { + if candidate.Timestamp != nil && + (existing.Timestamp == nil || candidate.Timestamp.After(*existing.Timestamp)) { + existing.Timestamp = candidate.Timestamp + } + if shouldReplaceSessionIP(existing.IPAddress, candidate.IPAddress) { + existing.IPAddress = candidate.IPAddress + } + if existing.UserAgent == "" && candidate.UserAgent != "" { + existing.UserAgent = candidate.UserAgent + } + if existing.ClientID == "" && candidate.ClientID != "" { + existing.ClientID = candidate.ClientID + } + if existing.AppName == "" && candidate.AppName != "" { + existing.AppName = candidate.AppName + } + return existing +} + +func shouldReplaceSessionIP(existing string, candidate string) bool { + existing = strings.TrimSpace(existing) + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return false + } + if existing == "" { + return true + } + if isPrivateIPAddress(existing) && !isPrivateIPAddress(candidate) { + return true + } + return false +} + +func isPrivateIPAddress(raw string) bool { + return utils.IsPrivateOrReservedIP(raw) +} + +func parseAuditDetails(details string) (map[string]any, error) { + return utils.ParseAuditDetails(details) +} + +func deriveSessionClientInfo(log domain.AuditLog) (string, string) { + details, _ := parseAuditDetails(log.Details) + clientID := "" + appName := "" + if details != nil { + if value, ok := details["client_id"].(string); ok { + clientID = strings.TrimSpace(value) + } + if value, ok := details["client_name"].(string); ok { + appName = strings.TrimSpace(value) + } + } + path := strings.ToLower(extractAuditPath(log)) + if appName == "" { + switch { + case strings.Contains(path, "/api/v1/auth/oidc/login/accept"): + appName = "OIDC 로그인" + case strings.Contains(path, "/api/v1/auth/qr/approve"): + appName = "QR 로그인" + case strings.Contains(path, "/api/v1/auth/login/code/verify"): + appName = "코드 로그인" + case strings.Contains(path, "/api/v1/auth/magic-link/verify"): + appName = "링크 로그인" + case strings.Contains(path, "/api/v1/auth/password/login"): + appName = "비밀번호 로그인" + } + } + if appName == "" && clientID != "" { + appName = clientID + } + return clientID, appName +} + +func extractStringLikeValue(raw any) string { + switch value := raw.(type) { + case string: + return strings.TrimSpace(value) + default: + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || text == "" { + return "" + } + return text + } +} + +func extractHydraSessionID(ext map[string]interface{}) string { + if len(ext) == 0 { + return "" + } + for _, key := range []string{"session_id", "sid", "sessionId"} { + if value := extractStringLikeValue(ext[key]); value != "" { + return value + } + } + return "" +} + +func (h *AuthHandler) validateHydraTokenSession(ctx context.Context, intro *service.HydraIntrospectionResponse) error { + if h == nil || h.KratosAdmin == nil || intro == nil { + return nil + } + + sessionID := extractHydraSessionID(intro.Ext) + if sessionID == "" { + return nil + } + + session, err := h.KratosAdmin.GetSession(ctx, sessionID) + if err != nil { + return fmt.Errorf("kratos session lookup failed: %w", err) + } + if session == nil { + return errors.New("linked session not found") + } + if !session.Active { + return errors.New("linked session is inactive") + } + if identityID := strings.TrimSpace(session.Identity.ID); identityID != "" && strings.TrimSpace(intro.Subject) != "" && identityID != strings.TrimSpace(intro.Subject) { + return errors.New("linked session subject mismatch") + } + return nil +} + +func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID string) map[string][]string { + bindings := make(map[string][]string) + if h == nil || h.AuditRepo == nil || strings.TrimSpace(userID) == "" { + return bindings + } + + logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{ + "consent.granted", + "POST /api/v1/auth/oidc/login/accept", + "POST /api/v1/auth/password/login", + "password_login_success", + "login_success", + }, 200) + if err != nil { + return bindings + } + + for _, log := range logs { + sessionID := strings.TrimSpace(log.SessionID) + if sessionID == "" { + sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details)) + } + if sessionID == "" { + continue + } + + clientID, _ := deriveSessionClientInfo(log) + clientID = strings.TrimSpace(clientID) + if clientID == "" { + continue + } + + existing := bindings[sessionID] + seen := false + for _, candidate := range existing { + if candidate == clientID { + seen = true + break + } + } + if !seen { + bindings[sessionID] = append(existing, clientID) + } + } + + return bindings +} + +func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID string, sessionID string) error { + if h == nil || h.Hydra == nil { + return nil + } + + clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)] + if len(clientIDs) == 0 { + return nil + } + for _, clientID := range clientIDs { + if err := h.Hydra.RevokeConsentSessions(ctx, userID, clientID); err != nil { + return err + } + } + return nil +} + +func looksLikeInternalUserAgent(userAgent string) bool { + normalized := strings.ToLower(strings.TrimSpace(userAgent)) + if normalized == "" { + return false + } + return strings.HasPrefix(normalized, "go-http-client/") || + strings.HasPrefix(normalized, "fasthttp") || + strings.HasPrefix(normalized, "fiber") +} + +func (h *AuthHandler) writeSessionRevokedAuditLog(c *fiber.Ctx, actorIdentityID string, actorSessionID string, targetSessionID string, result string) { + if h.AuditRepo == nil { + return + } + + details := map[string]any{ + "target_session_id": strings.TrimSpace(targetSessionID), + "revoke_result": strings.TrimSpace(result), + } + if strings.TrimSpace(actorSessionID) != "" { + details["actor_session_id"] = strings.TrimSpace(actorSessionID) + } + raw, err := json.Marshal(details) + if err != nil { + return + } + + _ = h.AuditRepo.Create(&domain.AuditLog{ + EventID: fmt.Sprintf("session-revoked-%d", time.Now().UnixNano()), + Timestamp: time.Now().UTC(), + UserID: strings.TrimSpace(actorIdentityID), + SessionID: strings.TrimSpace(actorSessionID), + EventType: "session.revoked", + Status: "success", + IPAddress: extractClientIPFromHeaders(c), + UserAgent: strings.TrimSpace(c.Get("User-Agent")), + Details: string(raw), + }) +} diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 5b4344ab..5f3a3a98 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -80,6 +80,7 @@ func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error } return args.Error(0) } + func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error { args := m.Called(ctx, user) if m.createCalled != nil { @@ -87,6 +88,7 @@ func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error } return args.Error(0) } + func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil } func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil diff --git a/backend/internal/handler/auth_handler_linked_test.go b/backend/internal/handler/auth_handler_linked_test.go index b9618d77..f4ec811a 100644 --- a/backend/internal/handler/auth_handler_linked_test.go +++ b/backend/internal/handler/auth_handler_linked_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -45,11 +46,14 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ { "client": map[string]interface{}{ - "client_id": "client-active", - "client_name": "Active App", + "client_id": "devfront", + "client_name": "DevFront", + "redirect_uris": []string{ + "https://active.example.com/callback", + }, }, - "granted_scope": []string{"openid"}, - "handled_at": time.Now().Format(time.RFC3339), + "grant_scope": []string{"openid", "profile"}, + "handled_at": time.Now().Format(time.RFC3339), }, }), nil } @@ -111,6 +115,8 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test") + t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc") + t.Setenv("DEVFRONT_URL", "http://localhost:5174") app := newLinkedRpTestApp(h) @@ -123,10 +129,11 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { var res struct { Items []struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Scopes []string `json:"scopes"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Scopes []string `json:"scopes"` + InitURL string `json:"init_url"` } `json:"items"` } json.NewDecoder(resp.Body).Decode(&res) @@ -138,7 +145,108 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) { statusMap[item.ID] = item.Status } - assert.Equal(t, "active", statusMap["client-active"]) + assert.Equal(t, "active", statusMap["devfront"]) assert.Equal(t, "inactive", statusMap["client-consent"]) assert.Equal(t, "inactive", statusMap["client-audit"]) + + var activeInitURL string + for _, item := range res.Items { + if item.ID == "devfront" { + activeInitURL = item.InitURL + break + } + } + + parsedInitURL, err := url.Parse(activeInitURL) + assert.NoError(t, err) + assert.Equal(t, "http", parsedInitURL.Scheme) + assert.Equal(t, "localhost:5174", parsedInitURL.Host) + assert.Equal(t, "/login", parsedInitURL.Path) + assert.Equal(t, "1", parsedInitURL.Query().Get("auto")) + assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo")) +} + +func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadata(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": "user-123", + }, + }), nil + } + case "hydra.test": + if r.URL.Path == "/oauth2/auth/sessions/consent" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + { + "client": map[string]interface{}{ + "client_id": "gitea-client", + "client_name": "Gitea", + "redirect_uris": []string{ + "https://gitea.example.com/callback", + }, + }, + "grant_scope": []string{"openid", "profile"}, + "handled_at": time.Now().Format(time.RFC3339), + }, + }), nil + } + if r.URL.Path == "/clients/gitea-client" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "gitea-client", + "client_name": "Gitea", + "redirect_uris": []string{ + "https://gitea.example.com/callback", + }, + "metadata": map[string]interface{}{ + "logo_url": "https://cdn.example.com/gitea.svg", + }, + }), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + + origDefault := http.DefaultClient + http.DefaultClient = client + defer func() { + http.DefaultClient = origDefault + }() + + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test") + t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc") + + app := newLinkedRpTestApp(h) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res struct { + Items []struct { + ID string `json:"id"` + Logo string `json:"logo"` + } `json:"items"` + } + json.NewDecoder(resp.Body).Decode(&res) + + assert.Len(t, res.Items, 1) + assert.Equal(t, "gitea-client", res.Items[0].ID) + assert.Equal(t, "https://cdn.example.com/gitea.svg", res.Items[0].Logo) } diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 5386bdc5..747f476e 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -7,6 +7,7 @@ package handler import ( "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/service" "bytes" "context" @@ -122,6 +123,27 @@ func (m *MockKratosAdminService) DeleteIdentity(ctx context.Context, identityID return nil } +func (m *MockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + args := m.Called(ctx, identityID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error { + args := m.Called(ctx, sessionID) + return args.Error(0) +} + // --- Helper --- func newAuthLoginTestApp(h *AuthHandler) *fiber.App { @@ -616,6 +638,156 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) { } } +func TestPasswordLogin_OIDC_AuditIncludesClientMetadata(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, + Subject: "kratos-identity-id", + }, nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: "challenge-123", + Client: domain.HydraClient{ + ClientID: "devfront", + ClientName: "DevFront", + Metadata: map[string]interface{}{"status": "active"}, + }, + }) + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + default: + http.NotFound(w, r) + } + }) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + app := fiber.New() + app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ + Repo: auditRepo, + BodyDump: true, + })) + app.Post("/api/v1/auth/password/login", h.PasswordLogin) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + if len(auditRepo.logs) != 1 { + t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) + } + + log := auditRepo.logs[0] + if log.EventType != "POST /api/v1/auth/password/login" { + t.Fatalf("expected password login audit event, got %q", log.EventType) + } + if log.UserID != "kratos-identity-id" { + t.Fatalf("expected audit user_id kratos-identity-id, got %q", log.UserID) + } + + details, err := parseAuditDetails(log.Details) + if err != nil { + t.Fatalf("failed to parse audit details: %v", err) + } + if got, _ := details["client_id"].(string); got != "devfront" { + t.Fatalf("expected client_id devfront, got %v", details["client_id"]) + } + if got, _ := details["client_name"].(string); got != "DevFront" { + t.Fatalf("expected client_name DevFront, got %v", details["client_name"]) + } +} + +func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, + Subject: "kratos-identity-id", + }, nil) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ + Repo: auditRepo, + BodyDump: true, + })) + app.Post("/api/v1/auth/password/login", h.PasswordLogin) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + if len(auditRepo.logs) != 1 { + t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) + } + if auditRepo.logs[0].UserID != "kratos-identity-id" { + t.Fatalf("expected audit user_id kratos-identity-id, got %q", auditRepo.logs[0].UserID) + } + + details, err := parseAuditDetails(auditRepo.logs[0].Details) + if err != nil { + t.Fatalf("failed to parse audit details: %v", err) + } + if got, _ := details["client_id"].(string); got != "userfront" { + t.Fatalf("expected client_id userfront, got %v", details["client_id"]) + } + if got, _ := details["client_name"].(string); got != "UserFront" { + t.Fatalf("expected client_name UserFront, got %v", details["client_name"]) + } +} + func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go new file mode 100644 index 00000000..817daf86 --- /dev/null +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -0,0 +1,685 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestListMySessions_Success(t *testing.T) { + now := time.Date(2026, 4, 2, 1, 2, 3, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "other-sid", + Active: true, + AuthenticatedAt: now.Add(-2 * time.Hour), + ExpiresAt: now.Add(22 * time.Hour), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "login_success", + SessionID: "other-sid", + Timestamp: now.Add(-30 * time.Minute), + IPAddress: "203.0.113.10", + UserAgent: "Mozilla/5.0", + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + IsCurrent bool `json:"is_current"` + IsActive bool `json:"is_active"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "current-sid", body.Items[0].SessionID) + assert.True(t, body.Items[0].IsCurrent) + assert.Equal(t, "other-sid", body.Items[1].SessionID) + assert.True(t, body.Items[1].IsActive) + assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress) + assert.Equal(t, "Mozilla/5.0", body.Items[1].UserAgent) + } + + mockKratos.AssertExpectations(t) +} + +func TestListMySessions_UsesConsentGrantForAppName(t *testing.T) { + now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "c7c721ea-session", + Active: true, + AuthenticatedAt: now.Add(-5 * time.Minute), + ExpiresAt: now.Add(23*time.Hour + 55*time.Minute), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.granted", + SessionID: "c7c721ea-session", + Timestamp: now, + Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session","approved_session_id":"c7c721ea-session"}`, + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + AppName string `json:"app_name"` + ClientID string `json:"client_id"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID) + assert.Equal(t, "DevFront", body.Items[1].AppName) + assert.Equal(t, "devfront", body.Items[1].ClientID) + } + + mockKratos.AssertExpectations(t) +} + +func TestListMySessions_PreservesAppNameFromOlderConsentGrant(t *testing.T) { + now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "c7c721ea-session", + Active: true, + AuthenticatedAt: now.Add(-5 * time.Minute), + ExpiresAt: now.Add(23*time.Hour + 55*time.Minute), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.granted", + SessionID: "c7c721ea-session", + Timestamp: now.Add(-30 * time.Second), + IPAddress: "203.0.113.10", + Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session"}`, + }, + { + UserID: "user-123", + EventType: "login_success", + SessionID: "c7c721ea-session", + Timestamp: now, + IPAddress: "10.0.0.12", + UserAgent: "Mozilla/5.0", + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + AppName string `json:"app_name"` + ClientID string `json:"client_id"` + IPAddress string `json:"ip_address"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID) + assert.Equal(t, "DevFront", body.Items[1].AppName) + assert.Equal(t, "devfront", body.Items[1].ClientID) + assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress) + } + + mockKratos.AssertExpectations(t) +} + +func TestListMySessions_CurrentSessionFallsBackToRequestMetadata(t *testing.T) { + now := time.Date(2026, 4, 6, 1, 2, 3, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + }, nil).Once() + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: &mockAuditRepo{}, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + IsCurrent bool `json:"is_current"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ClientID string `json:"client_id"` + AppName string `json:"app_name"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 1) { + assert.Equal(t, "current-sid", body.Items[0].SessionID) + assert.True(t, body.Items[0].IsCurrent) + assert.Equal(t, "203.0.113.25", body.Items[0].IPAddress) + assert.Contains(t, body.Items[0].UserAgent, "Mozilla/5.0") + assert.Equal(t, "userfront", body.Items[0].ClientID) + assert.Equal(t, "UserFront", body.Items[0].AppName) + } + + mockKratos.AssertExpectations(t) +} + +func TestDeleteMySession_Success(t *testing.T) { + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + var hydraRevokeCalls int + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": time.Now().UTC().Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + if r.URL.Query().Get("subject") != "user-123" { + t.Fatalf("unexpected revoke subject: %s", r.URL.Query().Get("subject")) + } + if r.URL.Query().Get("client") != "devfront" { + t.Fatalf("unexpected revoke client: %s", r.URL.Query().Get("client")) + } + hydraRevokeCalls++ + return httpResponse(r, http.StatusNoContent, ""), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + setDefaultHTTPClientForTest(t, client.Transport) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + auditRepo.logs = append(auditRepo.logs, domain.AuditLog{ + UserID: "user-123", + EventType: "POST /api/v1/auth/oidc/login/accept", + SessionID: "target-sid", + Details: `{"client_id":"devfront","client_name":"Devfront"}`, + }) + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + if assert.Len(t, auditRepo.logs, 2) { + assert.Equal(t, "session.revoked", auditRepo.logs[len(auditRepo.logs)-1].EventType) + assert.Equal(t, "user-123", auditRepo.logs[len(auditRepo.logs)-1].UserID) + assert.Equal(t, "current-sid", auditRepo.logs[len(auditRepo.logs)-1].SessionID) + assert.Contains(t, auditRepo.logs[len(auditRepo.logs)-1].Details, "target-sid") + } + assert.Equal(t, 1, hydraRevokeCalls) + + mockKratos.AssertExpectations(t) +} + +func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t *testing.T) { + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + var hydraRevokeCalls int + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": time.Now().UTC().Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + hydraRevokeCalls++ + return httpResponse(r, http.StatusNoContent, ""), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + setDefaultHTTPClientForTest(t, client.Transport) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 0, hydraRevokeCalls) + if assert.Len(t, auditRepo.logs, 1) { + assert.Equal(t, "session.revoked", auditRepo.logs[0].EventType) + assert.Equal(t, "user-123", auditRepo.logs[0].UserID) + assert.Contains(t, auditRepo.logs[0].Details, "target-sid") + } + + mockKratos.AssertExpectations(t) +} + +func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) { + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + var hydraRevokeCalls int + var revokedClient string + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Host { + case "kratos.test": + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": time.Now().UTC().Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + case "hydra.test": + if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { + revokedClient = r.URL.Query().Get("client") + hydraRevokeCalls++ + return httpResponse(r, http.StatusNoContent, ""), nil + } + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + setDefaultHTTPClientForTest(t, client.Transport) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + {ID: "target-sid", Active: true}, + }, nil).Once() + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: true, + }, nil).Once() + mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + auditRepo.logs = append(auditRepo.logs, domain.AuditLog{ + UserID: "user-123", + EventType: "POST /api/v1/auth/password/login", + SessionID: "target-sid", + Details: `{"client_id":"adminfront","client_name":"AdminFront","session_id":"target-sid"}`, + }) + + app := fiber.New() + app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "session-test-agent") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 1, hydraRevokeCalls) + assert.Equal(t, "adminfront", revokedClient) + + mockKratos.AssertExpectations(t) +} + +func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "hydra.test" && r.URL.Path == "/oauth2/introspect" { + body, _ := io.ReadAll(r.Body) + if string(body) != "token=opaque-token" { + t.Fatalf("unexpected introspect body: %s", string(body)) + } + return httpJSONAny(r, http.StatusOK, map[string]any{ + "active": true, + "sub": "user-123", + "client_id": "devfront", + "ext": map[string]any{ + "session_id": "target-sid", + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + + mockKratos := new(MockKratosAdminService) + mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ + ID: "target-sid", + Active: false, + Identity: &service.KratosIdentity{ + ID: "user-123", + }, + }, nil).Once() + + h := &AuthHandler{ + KratosAdmin: mockKratos, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + } + + profile, err := h.getHydraProfile(context.Background(), "opaque-token") + assert.Nil(t, profile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "inactive") + mockKratos.AssertExpectations(t) +} + +func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) { + now := time.Date(2026, 4, 7, 4, 39, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + h := &AuthHandler{ + AuditRepo: &mockAuditRepo{}, + OathkeeperRepo: &mockOathkeeperRepo{ + logs: []domain.OathkeeperAccessLog{ + { + Timestamp: now, + RequestID: "req-1", + Method: http.MethodGet, + Path: "/api/v1/dev/sessions", + Status: http.StatusOK, + Subject: "user-123", + ClientIP: "203.0.113.7", + UserAgent: "Mozilla/5.0", + Raw: `{"request":{"url":"https://devfront.example.com/callback?client_id=devfront"},"extra":{"session_id":"target-sid"}}`, + }, + }, + }, + } + + app := fiber.New() + app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + AppName string `json:"app_name"` + Source string `json:"source"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 1) { + assert.Equal(t, "target-sid", body.Items[0].SessionID) + assert.Equal(t, "devfront", body.Items[0].ClientID) + assert.Equal(t, "devfront", body.Items[0].AppName) + assert.Equal(t, "oathkeeper", body.Items[0].Source) + } +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 32bd1d21..c499eb39 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -115,6 +115,25 @@ func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil } +type mockOathkeeperRepo struct { + logs []domain.OathkeeperAccessLog +} + +func (m *mockOathkeeperRepo) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) { + if subject == "" { + return m.logs, nil + } + results := make([]domain.OathkeeperAccessLog, 0, len(m.logs)) + for _, log := range m.logs { + if log.Subject == subject { + results = append(results, log) + } + } + return results, nil +} + +func (m *mockOathkeeperRepo) Ping(ctx context.Context) error { return nil } + // --- Mock Consent Repository --- type mockConsentRepo struct { diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 8ed8f550..721cd1fd 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -56,6 +56,26 @@ func (m *MockKratosAdmin) DeleteIdentity(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } +func (m *MockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + args := m.Called(ctx, identityID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.KratosSession), args.Error(1) +} + +func (m *MockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error { + return m.Called(ctx, sessionID).Error(0) +} + type MockOryProvider struct { mock.Mock } diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index 59746e1d..a0c5c6fe 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "reflect" - "strings" "sync" "time" @@ -217,16 +216,5 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { } func extractClientIP(c *fiber.Ctx) string { - if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } - } - } - if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { - return realIP - } - return c.IP() + return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP()) } diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go index 9998429b..d553ad40 100644 --- a/backend/internal/middleware/audit_middleware_test.go +++ b/backend/internal/middleware/audit_middleware_test.go @@ -117,6 +117,30 @@ func TestAuditMiddleware(t *testing.T) { mockRepo.AssertExpectations(t) }) + t.Run("POST request - Prefer public forwarded IP", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + })) + + app.Post("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool { + return log.IPAddress == "203.0.113.25" + })).Return(nil) + + req := httptest.NewRequest("POST", "/test", nil) + req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25") + + resp, _ := app.Test(req) + assert.Equal(t, fiber.StatusOK, resp.StatusCode) + mockRepo.AssertExpectations(t) + }) + t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) { app := fiber.New() mockRepo := new(MockAuditRepository) diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index 6bafc76c..8dda5318 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -264,6 +264,8 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, } if clientID != "" { params["client"] = clientID + } else { + params["all"] = "true" } endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params) if err != nil { diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index dce9813a..a448ba59 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -28,6 +28,21 @@ type KratosIdentity struct { UpdatedAt time.Time `json:"updated_at,omitempty"` } +type KratosSessionDevice struct { + UserAgent string `json:"user_agent,omitempty"` + IPAddress string `json:"ip_address,omitempty"` +} + +type KratosSession struct { + ID string `json:"id"` + Active bool `json:"active"` + AuthenticatedAt time.Time `json:"authenticated_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + IssuedAt time.Time `json:"issued_at,omitempty"` + Identity *KratosIdentity `json:"identity,omitempty"` + Devices []KratosSessionDevice `json:"devices,omitempty"` +} + type KratosAdminService interface { ListIdentities(ctx context.Context) ([]KratosIdentity, error) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) @@ -245,7 +260,7 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker if user == nil { return "", fmt.Errorf("kratos admin: user payload is nil") } - + traits := map[string]interface{}{ "email": user.Email, "name": user.Name, diff --git a/backend/internal/service/mock_common_test.go b/backend/internal/service/mock_common_test.go index eb514efd..1301ef30 100644 --- a/backend/internal/service/mock_common_test.go +++ b/backend/internal/service/mock_common_test.go @@ -116,5 +116,3 @@ func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *dom args := m.Called(ctx, user, password) return args.String(0), args.Error(1) } - - diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index a486b19c..21901e8f 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -150,6 +150,7 @@ func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs } return args.Get(0).(map[string]int64), args.Error(1) } + func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { args := m.Called(ctx, codes) if args.Get(0) == nil { diff --git a/backend/internal/utils/client_ip.go b/backend/internal/utils/client_ip.go new file mode 100644 index 00000000..897cc1e6 --- /dev/null +++ b/backend/internal/utils/client_ip.go @@ -0,0 +1,87 @@ +package utils + +import ( + "net" + "strings" +) + +// ResolveClientIP selects the best client IP from proxy headers and the remote address. +// It prefers a public IP from X-Forwarded-For, then X-Real-IP, and finally the remote IP. +func ResolveClientIP(forwardedFor, realIP, remoteIP string) string { + forwardedCandidates := splitClientIPs(forwardedFor) + if ip := firstPublicIP(forwardedCandidates); ip != "" { + return ip + } + if ip := normalizeIP(realIP); ip != "" && !IsPrivateOrReservedIP(ip) { + return ip + } + if ip := normalizeIP(remoteIP); ip != "" && !IsPrivateOrReservedIP(ip) { + return ip + } + if len(forwardedCandidates) > 0 { + return forwardedCandidates[0] + } + if ip := normalizeIP(realIP); ip != "" { + return ip + } + return normalizeIP(remoteIP) +} + +// IsPrivateOrReservedIP reports whether the IP is private or from a non-public network range. +func IsPrivateOrReservedIP(raw string) bool { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil { + return false + } + if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() { + return true + } + for _, cidr := range []string{ + "100.64.0.0/10", + "fc00::/7", + } { + _, network, err := net.ParseCIDR(cidr) + if err == nil && network.Contains(ip) { + return true + } + } + return false +} + +func splitClientIPs(forwardedFor string) []string { + if strings.TrimSpace(forwardedFor) == "" { + return nil + } + parts := strings.Split(forwardedFor, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if ip := normalizeIP(part); ip != "" { + result = append(result, ip) + } + } + return result +} + +func firstPublicIP(candidates []string) string { + for _, candidate := range candidates { + if !IsPrivateOrReservedIP(candidate) { + return candidate + } + } + return "" +} + +func normalizeIP(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if host, _, err := net.SplitHostPort(raw); err == nil { + raw = host + } + ip := net.ParseIP(raw) + if ip == nil { + return "" + } + return ip.String() +} diff --git a/backend/internal/utils/client_ip_test.go b/backend/internal/utils/client_ip_test.go new file mode 100644 index 00000000..8128fc87 --- /dev/null +++ b/backend/internal/utils/client_ip_test.go @@ -0,0 +1,24 @@ +package utils + +import "testing" + +func TestResolveClientIP_PrefersPublicForwardedIP(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 203.0.113.25, 10.0.0.2", "", "172.18.0.5") + if got != "203.0.113.25" { + t.Fatalf("expected public forwarded IP, got %q", got) + } +} + +func TestResolveClientIP_FallsBackToFirstForwardedWhenAllPrivate(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 10.0.0.2", "192.168.0.10", "172.18.0.5") + if got != "100.100.100.1" { + t.Fatalf("expected first forwarded private IP, got %q", got) + } +} + +func TestResolveClientIP_PrefersPublicRealIPOverPrivateForwarded(t *testing.T) { + got := ResolveClientIP("100.100.100.1, 10.0.0.2", "198.51.100.7", "172.18.0.5") + if got != "198.51.100.7" { + t.Fatalf("expected public real IP, got %q", got) + } +} diff --git a/devfront/package.json b/devfront/package.json index 4dd04e0b..8809ce3a 100644 --- a/devfront/package.json +++ b/devfront/package.json @@ -7,7 +7,7 @@ "node": ">=24.0.0" }, "scripts": { - "dev": "vite --host 0.0.0.0", + "dev": "vite --host 127.0.0.1", "build": "tsc -b && vite build", "lint": "biome check .", "preview": "vite preview", diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 21c43d00..4e0eb33b 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -15,7 +15,10 @@ import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { fetchMe } from "../../features/auth/authApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; -import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding"; +import { + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../lib/sessionSliding"; import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; @@ -151,6 +154,52 @@ function AppLayout() { isSessionExpiryEnabled, ]); + useEffect(() => { + const maybeKeepSessionAlive = async () => { + const now = Date.now(); + if ( + !shouldAttemptUnlimitedSessionRenew({ + expiresAtSec: auth.user?.expires_at, + nowMs: now, + isEnabled: isSessionExpiryEnabled, + isAuthenticated: auth.isAuthenticated, + isLoading: auth.isLoading, + isRenewInFlight: isRenewInFlightRef.current, + lastAttemptAtMs: lastRenewAttemptAtRef.current, + }) + ) { + return; + } + + isRenewInFlightRef.current = true; + lastRenewAttemptAtRef.current = now; + + try { + await auth.signinSilent(); + } catch (error) { + console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + } finally { + isRenewInFlightRef.current = false; + } + }; + + const timer = window.setInterval(() => { + void maybeKeepSessionAlive(); + }, 30_000); + + void maybeKeepSessionAlive(); + + return () => { + window.clearInterval(timer); + }; + }, [ + auth, + auth.isAuthenticated, + auth.isLoading, + auth.user?.expires_at, + isSessionExpiryEnabled, + ]); + useEffect(() => { const routeKey = `${location.pathname}${location.search}${location.hash}`; if (lastVisitedRouteRef.current === null) { diff --git a/devfront/src/features/auth/AuthCallbackPage.tsx b/devfront/src/features/auth/AuthCallbackPage.tsx index 929bc7b4..1cf9be59 100644 --- a/devfront/src/features/auth/AuthCallbackPage.tsx +++ b/devfront/src/features/auth/AuthCallbackPage.tsx @@ -17,12 +17,19 @@ export default function AuthCallbackPage() { } if (auth.isAuthenticated) { - navigate("/", { replace: true }); + const returnTo = + typeof auth.user?.state === "object" && + auth.user?.state !== null && + "returnTo" in auth.user.state && + typeof auth.user.state.returnTo === "string" + ? auth.user.state.returnTo + : "/clients"; + navigate(returnTo, { replace: true }); } else if (auth.error) { console.error("Auth Error:", auth.error); navigate("/login", { replace: true }); } - }, [auth.isAuthenticated, auth.error, navigate]); + }, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]); return
Loading Auth...
; } diff --git a/devfront/src/features/auth/LoginPage.tsx b/devfront/src/features/auth/LoginPage.tsx index 3c87a65e..212eabf7 100644 --- a/devfront/src/features/auth/LoginPage.tsx +++ b/devfront/src/features/auth/LoginPage.tsx @@ -1,7 +1,8 @@ import { ExternalLink, LogIn, ShieldHalf } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useAuth } from "react-oidc-context"; import { useNavigate } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { Button } from "../../components/ui/button"; import { Card, @@ -14,18 +15,42 @@ import { function LoginPage() { const auth = useAuth(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const autoStartedRef = useRef(false); + const returnTo = searchParams.get("returnTo") || "/clients"; + const shouldAutoLogin = searchParams.get("auto") === "1"; useEffect(() => { if (auth.isAuthenticated) { - navigate("/clients", { replace: true }); + navigate(returnTo, { replace: true }); } - }, [auth.isAuthenticated, navigate]); + }, [auth.isAuthenticated, navigate, returnTo]); + + useEffect(() => { + if (!shouldAutoLogin) { + return; + } + if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) { + return; + } + + autoStartedRef.current = true; + void auth.signinRedirect({ + state: { + returnTo, + }, + }); + }, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]); const handleSSOLogin = async () => { try { - await auth.signinPopup(); + await auth.signinRedirect({ + state: { + returnTo: "/clients", + }, + }); } catch (error) { - console.error("Popup login failed", error); + console.error("Redirect login failed", error); } }; diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 1fa793c6..570d7b01 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -9,7 +9,7 @@ import { Save, Shield, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -44,7 +44,7 @@ function ClientDetailsPage() { const queryClient = useQueryClient(); const clientId = params.id ?? ""; - const { data, isLoading, error } = useQuery({ + const { data, error, isLoading } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), enabled: clientId.length > 0, @@ -52,12 +52,18 @@ function ClientDetailsPage() { const [redirectUris, setRedirectUris] = useState(""); const [showSecret, setShowSecret] = useState(false); + const redirectUrisHydratedRef = useRef(false); useEffect(() => { - if (data?.client?.redirectUris) { + if ( + !redirectUrisHydratedRef.current && + data?.client?.redirectUris && + redirectUris === "" + ) { setRedirectUris(data.client.redirectUris.join(", ")); + redirectUrisHydratedRef.current = true; } - }, [data]); + }, [data, redirectUris]); const mutation = useMutation({ mutationFn: () => { @@ -129,15 +135,7 @@ function ClientDetailsPage() { ); } - if (isLoading) { - return ( -
- {t("msg.dev.clients.details.loading", "Loading app...")} -
- ); - } - - if (error || !data) { + if (error && !data) { const errMsg = (error as AxiosError<{ error?: string }>).response?.data?.error ?? (error as Error)?.message; @@ -152,37 +150,56 @@ function ClientDetailsPage() { ); } + if (isLoading && !data) { + return ( +
+ {t("msg.dev.clients.details.loading", "Loading app details...")} +
+ ); + } + + const client = data?.client; + if (!client) { + return null; + } + const endpointValues = data?.endpoints ?? { + discovery: "-", + issuer: "-", + authorization: "-", + token: "-", + userinfo: "-", + }; const endpoints = [ { labelKey: "ui.dev.clients.details.endpoint.discovery", labelFallback: "Discovery Endpoint", - value: data.endpoints.discovery, + value: endpointValues.discovery, }, { labelKey: "ui.dev.clients.details.endpoint.issuer", labelFallback: "Issuer URL", - value: data.endpoints.issuer, + value: endpointValues.issuer, }, { labelKey: "ui.dev.clients.details.endpoint.authorization", labelFallback: "Authorization Endpoint", - value: data.endpoints.authorization, + value: endpointValues.authorization, }, { labelKey: "ui.dev.clients.details.endpoint.token", labelFallback: "Token Endpoint", - value: data.endpoints.token, + value: endpointValues.token, }, { labelKey: "ui.dev.clients.details.endpoint.userinfo", labelFallback: "UserInfo Endpoint", - value: data.endpoints.userinfo, + value: endpointValues.userinfo, }, ]; // Client Secret from API const secretPlaceholder = "SECRET_NOT_AVAILABLE"; - const clientSecret = data.client.clientSecret || secretPlaceholder; + const clientSecret = client?.clientSecret || secretPlaceholder; const displaySecret = clientSecret === secretPlaceholder ? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE") @@ -200,7 +217,7 @@ function ClientDetailsPage() { {t("ui.dev.clients.consents.breadcrumb.clients", "Apps")} / - {data.client.name || clientId} + {client?.name || clientId} / {t("ui.dev.clients.details.tab.connection", "Federation")} @@ -215,7 +232,7 @@ function ClientDetailsPage() {

- {data.client.name || data.client.id} + {client?.name || client?.id || clientId}

{t( @@ -226,12 +243,14 @@ function ClientDetailsPage() {

- {data.client.status === "active" + {client?.status === "active" ? t("ui.common.status.active", "Active") - : t("ui.common.status.inactive", "Inactive")} + : client?.status === "inactive" + ? t("ui.common.status.inactive", "Inactive") + : t("msg.common.loading", "Loading...")}
@@ -276,10 +295,10 @@ function ClientDetailsPage() {

- {data.client.id} + {client?.id || clientId}

toast( t( @@ -461,7 +480,10 @@ function ClientDetailsPage() { )} rows={5} value={redirectUris} - onChange={(e) => setRedirectUris(e.target.value)} + onChange={(e) => { + redirectUrisHydratedRef.current = true; + setRedirectUris(e.target.value); + }} className="font-mono text-sm" />
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 2446daf9..7d4faa51 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, + ExternalLink, Info, Plus, Save, @@ -133,6 +134,9 @@ function ClientGeneralPage() { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [logoUrl, setLogoUrl] = useState(""); + const [logoPreviewStatus, setLogoPreviewStatus] = useState< + "idle" | "loading" | "loaded" | "error" + >("idle"); const [clientType, setClientType] = useState("private"); const [status, setStatus] = useState("active"); const [initialStatus, setInitialStatus] = useState("active"); @@ -240,6 +244,21 @@ function ClientGeneralPage() { const securityProfile: SecurityProfile = clientType === "pkce" ? "pkce" : "private"; + const trimmedLogoUrl = logoUrl.trim(); + const hasLogoUrl = trimmedLogoUrl.length > 0; + const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl); + + useEffect(() => { + if (!hasLogoUrl) { + setLogoPreviewStatus("idle"); + return; + } + if (!hasValidLogoUrl) { + setLogoPreviewStatus("error"); + return; + } + setLogoPreviewStatus("loading"); + }, [hasLogoUrl, hasValidLogoUrl]); const handleSecurityProfileChange = (profile: SecurityProfile) => { setClientType(profile); @@ -438,6 +457,15 @@ function ClientGeneralPage() { const mutation = useMutation({ mutationFn: async () => { + if (hasLogoUrl && !hasValidLogoUrl) { + throw new Error( + t( + "msg.dev.clients.general.identity.logo_invalid", + "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.", + ), + ); + } + const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); const effectiveTokenEndpointAuthMethod = @@ -457,7 +485,7 @@ function ClientGeneralPage() { : undefined, metadata: { description, - logo_url: logoUrl, + logo_url: trimmedLogoUrl, structured_scopes: scopes, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, headless_login_enabled: headlessLoginEnabled, @@ -722,6 +750,8 @@ function ClientGeneralPage() { setLogoUrl(e.target.value)} + aria-invalid={!hasValidLogoUrl} + className={!hasValidLogoUrl ? "border-destructive" : ""} placeholder={t( "ui.dev.clients.general.identity.logo_placeholder", "https://example.com/logo.png", @@ -733,19 +763,102 @@ function ClientGeneralPage() { "인증 화면에 표시될 PNG/SVG URL입니다.", )}

+ {!hasValidLogoUrl ? ( +

+ {t( + "msg.dev.clients.general.identity.logo_invalid", + "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.", + )} +

+ ) : null} + {hasLogoUrl && hasValidLogoUrl ? ( +
+ + {logoPreviewStatus === "loading" + ? t( + "msg.dev.clients.general.identity.logo_preview_loading", + "로고 미리보기를 불러오는 중입니다.", + ) + : logoPreviewStatus === "loaded" + ? t( + "msg.dev.clients.general.identity.logo_preview_ready", + "로고 미리보기를 확인했습니다.", + ) + : logoPreviewStatus === "error" + ? t( + "msg.dev.clients.general.identity.logo_preview_failed", + "로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요.", + ) + : null} + + + + {t( + "ui.dev.clients.general.identity.logo_open", + "새 탭에서 열기", + )} + +
+ ) : null}
-
- {logoUrl ? ( +
+ {hasLogoUrl && hasValidLogoUrl ? ( {t( setLogoPreviewStatus("loaded")} + onError={() => setLogoPreviewStatus("error")} /> ) : ( - +
+ + {logoPreviewStatus === "error" ? ( + + {t( + "ui.dev.clients.general.identity.logo_preview_error_badge", + "미리보기 실패", + )} + + ) : ( + + {t( + "ui.dev.clients.general.identity.logo_preview_empty", + "미리보기", + )} + + )} +
)}
diff --git a/devfront/src/lib/apiClient.ts b/devfront/src/lib/apiClient.ts index 49b83cee..5a28cc72 100644 --- a/devfront/src/lib/apiClient.ts +++ b/devfront/src/lib/apiClient.ts @@ -27,17 +27,23 @@ apiClient.interceptors.request.use(async (config) => { apiClient.interceptors.response.use( (response) => response, async (error) => { - if (error.response?.status === 401) { - // 401 발생 시 로그인 페이지로 리다이렉트 - const isAuthPath = window.location.pathname.startsWith("/auth/callback"); - const isLoginPath = window.location.pathname === "/login"; - const user = await userManager.getUser(); - // 인증 토큰이 없는 경우에만 로그인으로 보낸다. - // 토큰이 있는데 401이면 권한/백엔드 정책 이슈로 간주하고 화면에서 에러를 노출한다. - const hasAccessToken = Boolean(user?.access_token); - if (!hasAccessToken && !isAuthPath && !isLoginPath) { - window.location.href = "/login"; - } + const status = error.response?.status; + const message = + error.response?.data?.error?.toString().toLowerCase() ?? + error.response?.data?.message?.toString().toLowerCase() ?? + ""; + const isAuthPath = window.location.pathname.startsWith("/auth/callback"); + const isLoginPath = window.location.pathname === "/login"; + const shouldRedirectToLogin = + status === 401 || + (status === 403 && + (message.includes("authentication required") || + message.includes("invalid session") || + message.includes("token is not active"))); + + if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) { + await userManager.removeUser(); + window.location.href = "/login"; } return Promise.reject(error); }, diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index f424d9d9..d0f0772e 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -11,7 +11,7 @@ export const oidcConfig: AuthProviderProps = { post_logout_redirect_uri: window.location.origin, popup_redirect_uri: `${window.location.origin}/auth/callback`, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: true, + automaticSilentRenew: false, }; export const userManager = new UserManager({ diff --git a/devfront/src/lib/sessionSliding.ts b/devfront/src/lib/sessionSliding.ts index 7096e7f3..be152778 100644 --- a/devfront/src/lib/sessionSliding.ts +++ b/devfront/src/lib/sessionSliding.ts @@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({ return true; } + +export function shouldAttemptUnlimitedSessionRenew({ + expiresAtSec, + nowMs, + isEnabled, + isAuthenticated, + isLoading, + isRenewInFlight, + lastAttemptAtMs, + thresholdMs = SESSION_RENEW_THRESHOLD_MS, + throttleMs = SESSION_RENEW_THROTTLE_MS, +}: SlidingSessionRenewDecisionParams) { + if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { + return false; + } + + if (typeof expiresAtSec !== "number") { + return false; + } + + const remainingMs = expiresAtSec * 1000 - nowMs; + if (remainingMs <= 0 || remainingMs > thresholdMs) { + return false; + } + + if (nowMs - lastAttemptAtMs < throttleMs) { + return false; + } + + return true; +} diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index d3ae2a09..87256821 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -377,6 +377,10 @@ empty = "No IdP configurations found." [msg.dev.clients.general.identity] logo_help = "PNG or SVG URL shown on the consent and authentication screens." +logo_invalid = "The app logo URL format is invalid. Enter an http or https address." +logo_preview_loading = "Loading the logo preview." +logo_preview_ready = "Logo preview loaded." +logo_preview_failed = "Failed to load the logo preview. Check the URL or image access policy." subtitle = "Set the application name, description, and logo." [msg.dev.clients.general.redirect] @@ -1378,6 +1382,9 @@ description_placeholder = "Description Placeholder" logo = "App Logo URL" logo_placeholder = "https://example.com/logo.png" logo_preview = "Logo Preview" +logo_open = "Open in new tab" +logo_preview_error_badge = "Preview failed" +logo_preview_empty = "Preview" name = "Name" name_placeholder = "My Awesome Application" title = "Application Identity" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index b6abb943..2ac3c74a 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -377,6 +377,10 @@ subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다." [msg.dev.clients.general.identity] logo_help = "인증 화면에 표시될 PNG/SVG URL입니다." +logo_invalid = "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요." +logo_preview_loading = "로고 미리보기를 불러오는 중입니다." +logo_preview_ready = "로고 미리보기를 확인했습니다." +logo_preview_failed = "로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요." subtitle = "앱 이름과 설명, 로고를 설정합니다." [msg.dev.clients.general.redirect] @@ -1377,6 +1381,9 @@ description_placeholder = "앱에 대한 간단한 설명을 입력하세요." logo = "앱 로고 URL" logo_placeholder = "https://example.com/logo.png" logo_preview = "로고 미리보기" +logo_open = "새 탭에서 열기" +logo_preview_error_badge = "미리보기 실패" +logo_preview_empty = "미리보기" name = "앱 이름" name_placeholder = "예: 멋진 애플리케이션" title = "애플리케이션 정보" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 87853adf..7bff5ee6 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -377,6 +377,10 @@ empty = "" [msg.dev.clients.general.identity] logo_help = "" +logo_invalid = "" +logo_preview_loading = "" +logo_preview_ready = "" +logo_preview_failed = "" subtitle = "" [msg.dev.clients.general.redirect] @@ -1378,6 +1382,9 @@ description_placeholder = "" logo = "" logo_placeholder = "" logo_preview = "" +logo_open = "" +logo_preview_error_badge = "" +logo_preview_empty = "" name = "" name_placeholder = "" title = "" @@ -1545,6 +1552,7 @@ ory = "" session = "" [ui.userfront.dashboard] +link_status_label = "" last_auth_label = "" status_history = "" diff --git a/devfront/tests/devfront-role-switch-report.spec.ts b/devfront/tests/devfront-role-switch-report.spec.ts index e6b2af07..633e6b89 100644 --- a/devfront/tests/devfront-role-switch-report.spec.ts +++ b/devfront/tests/devfront-role-switch-report.spec.ts @@ -147,6 +147,7 @@ test.describe("DevFront role report", () => { ); await page.getByRole("button", { name: /앱 생성|Create/i }).click(); await createPromise; + await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/); await expect .poll(() => state.auditLogs.some((item) => { diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index d3643940..d914f364 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -125,6 +125,7 @@ export async function seedAuth(page: Page, role?: string) { "oidc.user:http://localhost:5000/oidc/:devfront", JSON.stringify(mockOidcUser), ); + window.localStorage.setItem("dev_role", injectedRole || "rp_admin"); window.localStorage.setItem("dev_tenant_id", "tenant-a"); }, { issuedAt: nowInSeconds, injectedRole: role ?? "" }, @@ -196,6 +197,25 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { }); }; + await page.route("**/api/v1/user/me", async (route) => { + const storedRole = + (await page.evaluate(() => window.localStorage.getItem("dev_role"))) ?? + "rp_admin"; + return json(route, { + id: "playwright-user", + loginId: "playwright@example.com", + email: "playwright@example.com", + name: "Playwright User", + phoneNumber: "", + department: "QA", + tenantId: "tenant-a", + tenantName: "Tenant A", + role: storedRole, + createdAt: "2026-03-03T00:00:00.000Z", + updatedAt: "2026-03-03T00:00:00.000Z", + }); + }); + await page.route("**/api/v1/dev/**", async (route) => { const request = route.request(); const url = new URL(request.url()); diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts index 7ea28ab7..6bbde0af 100644 --- a/devfront/vite.config.ts +++ b/devfront/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], server: { - host: "0.0.0.0", + host: "127.0.0.1", allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], proxy: { "/api": { @@ -14,7 +14,7 @@ export default defineConfig({ }, }, preview: { - host: "0.0.0.0", + host: "127.0.0.1", port: 5173, allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"], proxy: { diff --git a/issue_489_completion_summary.md b/docs/trouble-shooting/issue_489_completion_summary.md similarity index 100% rename from issue_489_completion_summary.md rename to docs/trouble-shooting/issue_489_completion_summary.md diff --git a/locales/en.toml b/locales/en.toml index 28da2b1f..35d8e8c0 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -509,9 +509,11 @@ saved_success = "Saved successfully." greeting = "Hello, {{name}}." [msg.userfront.audit] +browser = "Browser: {{value}}" date = "Date: {{value}}" device = "Device: {{value}}" end = "No more items to show." +filtered_empty = "No sign-in history matches the active session filter." ip = "IP address: {{value}}" load_more_error = "Could not load more history." result = "Result: {{value}}" @@ -549,6 +551,7 @@ client_id = "Client ID: {{id}}" client_id_missing = "No client ID available." current_status = "Current status: {{status}}" last_auth = "Last signed in: {{value}}" +link_status = "Link status: {{status}}" link_missing = "This app does not have a launch URL configured." link_open_error = "Could not open the app link." render_error = "Dashboard render error: {{error}}" @@ -559,6 +562,20 @@ 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.sessions] +browser = "Browser: {{value}}" +empty = "No active sessions." +empty_detail = "Devices signed in with this account will appear here." +error = "Could not load sessions." +os = "OS: {{value}}" +recent_app = "Recent app: {{app}}" +session_id = "Session ID: {{id}}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "End the session for {{target}}?\nThat device will need to sign in again." +error = "Could not end the session: {{error}}" +success = "The session has been ended." + [msg.userfront.dashboard.approved_session] copy_click = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nClick to copy." copy_tap = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nTap to copy." @@ -735,6 +752,7 @@ 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." +sessions_subtitle = "Your currently signed-in devices and browser sessions." [msg.userfront.settings] disabled = "Account settings are currently unavailable." @@ -2040,8 +2058,10 @@ dev_console = "Dev Console" [ui.userfront.audit] [ui.userfront.audit.table] +action = "Action" app = "App" auth_method = "Auth Method" +browser = "Browser" date = "Date" device = "Device" ip = "IP" @@ -2065,11 +2085,23 @@ title = "Cancel consent" [ui.userfront.dashboard] last_auth_label = "Last sign-in" -status_history = "Activity history" +link_status_label = "Link status" +status_history = "Link details" [ui.userfront.dashboard.activity] linked = "Linked" +[ui.userfront.dashboard.sessions] +active_badge = "Active" +current_badge = "Current" +current_disabled = "Current session" +unknown_device = "Unknown device" +unknown_session = "Session" + +[ui.userfront.dashboard.sessions.revoke] +action = "End session" +title = "End session" + [ui.userfront.dashboard.approved_session] default = "Default" userfront = "Approved UserFront session ID" @@ -2079,7 +2111,7 @@ confirm_button = "Disconnect" title = "Disconnect app" [ui.userfront.dashboard.scopes] -title = "Permission (Scopes)" +title = "Consent scopes" [ui.userfront.dashboard.status] revoked = "Revoked" @@ -2204,6 +2236,7 @@ title = "Create a new password" [ui.userfront.sections] apps = "Apps" audit = "Audit" +sessions = "Sessions" [ui.userfront.session] active = "Active session" @@ -2251,3 +2284,11 @@ verify = "Verification" [ui.userfront.signup.success] action = "Go to sign-in" + + +[ui.userfront.audit.filter] +title = "Manage My Activity" +toggle_label = "Show active sessions only" + +[msg.userfront.audit.filter] +description = "Toggle to view only active sessions." diff --git a/locales/ko.toml b/locales/ko.toml index 3c995a3d..a89cea92 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -73,7 +73,406 @@ scope_admin = "Scoped to /admin" session_ttl = "Session TTL: 15m admin" tenant_headers = "Tenant-aware headers" -[msg.admin.api_keys] +[msg.admin.common] +forbidden = "이 작업을 수행할 권한이 없습니다." + +[msg.admin.audit] +empty = "아직 수집된 감사 로그가 없습니다." +end = "감사 로그의 마지막입니다." +load_error = "감사 로그를 불러오지 못했습니다: {{error}}" +loading = "감사 로그를 불러오는 중..." +subtitle = "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다." + +[msg.admin.header] +subtitle = "Tenant isolation & least privilege by default" + +[msg.admin.notice] +idp_policy = "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다." +scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다." + +[msg.admin.org] +hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다." +import_description = "CSV 파일을 업로드하여 조직도를 일괄 등록합니다." +import_error = "조직도 임포트 중 오류가 발생했습니다." +import_success = "조직도가 성공적으로 임포트되었습니다." + +[msg.admin.overview] +description = "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다." +idp_fallback = "Fallback: Descope" +idp_primary = "IDP: Ory primary" + +[msg.admin.tenants] +approve_confirm = "이 테넌트를 승인하시겠습니까?" +approve_success = "테넌트가 승인되었습니다." +delete_confirm = "테넌트 \"{{name}}\"를 삭제할까요?" +delete_success = "테넌트가 삭제되었습니다." +empty = "아직 등록된 테넌트가 없습니다." +fetch_error = "테넌트 목록 조회에 실패했습니다." +missing_id = "테넌트 ID가 없습니다." +not_found = "테넌트를 찾을 수 없습니다." +remove_sub_confirm = "테넌트 \"{{name}}\"을(를) 하위 조직에서 제외할까요?" +subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다." + +[msg.dev.auth] +access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요." +access_denied_title = "접근 권한이 없습니다." + +[msg.dev.forbidden] +default = "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요." +rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다." +tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다." +user = "일반 사용자는 관리자 화면에 접근할 수 없습니다." +title = "{{resource}} 접근 권한 없음" + +[msg.dev.audit] +empty = "조회된 감사 로그가 없습니다." +forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요." +load_error = "감사 로그 조회 실패: {{error}}" +loaded_count = "로드된 로그 {{count}}건" +loading = "감사 로그를 불러오는 중..." +subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다." + +[msg.dev.clients] +deleted = "앱이 삭제되었습니다." +delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." +delete_error = "삭제 실패: {{error}}" +load_error = "앱 정보를 불러오지 못했습니다: {{error}}" +loading = "앱 정보를 불러오는 중..." +showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." + +[msg.dev.sidebar] +notice = "개발자 전용 콘솔입니다." +notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다." + +[msg.dev.clients.general.public_key] +auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다." +auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다." +auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다." +guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json" +guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다." +guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다." +guide_step_2 = "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다." +guide_step_3 = "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다." +headless_help = "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다." +jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다." +jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json" +request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다." +source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다." +subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다." + +[msg.dev.clients.general.public_key.validation] +headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다." +headless_requires_private_key_jwt = "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다." +headless_requires_public_key = "Headless Login을 사용하려면 JWKS URI가 필요합니다." +invalid_jwks_inline = "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다." +invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다." +missing_jwks_inline = "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다." +missing_jwks_uri = "JWKS URI를 입력해야 합니다." +private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다." + +[msg.userfront.audit] +browser = "브라우저: {{value}}" +date = "접속일자: {{value}}" +device = "접속환경: {{value}}" +end = "더 이상 항목이 없습니다." +filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다." +ip = "접속 IP: {{value}}" +load_more_error = "더 불러오지 못했습니다." +result = "인증결과: {{value}}" +session_id = "Session ID: {{value}}" +status = "현황: (준비중)" + +[msg.userfront.dashboard] +approved_device = "승인 기기: {{device}}" +approved_ip = "승인 IP: {{ip}}" +audit_empty = "최근 접속 이력이 없습니다." +audit_load_error = "접속이력을 불러오지 못했습니다." +auth_method = "인증수단: {{method}}" +client_id = "Client ID: {{id}}" +client_id_missing = "Client ID 없음" +current_status = "현재 상태: {{status}}" +last_auth = "최근 인증: {{value}}" +link_status = "연동 상태: {{status}}" +link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." +link_open_error = "해당 링크를 열 수 없습니다." +render_error = "대시보드 렌더링 오류: {{error}}" +session_id_copied = "세션 ID가 복사되었습니다." + +[msg.userfront.error] +detail_contact = "관리자에게 문의해 주세요." +detail_generic = "오류가 발생했습니다." +detail_request = "요청을 처리하는 중 문제가 발생했습니다." +id = "오류 ID: {{id}}" +title = "인증 과정에서 오류가 발생했습니다" +title_generic = "오류가 발생했습니다" +title_with_code = "오류: {{code}}" +type = "오류 종류: {{type}}" + +[msg.userfront.forgot] +description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +error = "전송에 실패했습니다: {{error}}" +input_required = "이메일 또는 휴대폰 번호를 입력해주세요." +sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요." + +[msg.userfront.login] +cookie_check_failed = "로그인 확인 실패: {{error}}" +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +link_failed = "오류: {{error}}" +link_send_failed = "전송 실패: {{error}}" +link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다." +link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다." +link_timeout = "시간이 경과되었습니다." +no_account = "계정이 없으신가요?" +oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요." +qr_expired = "시간이 경과되었습니다." +qr_init_failed = "QR 초기화에 실패했습니다: {{error}}" +qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다" +token_missing = "로그인 토큰을 확인할 수 없습니다." +verification_failed = "승인 처리에 실패했습니다: {{error}}" + +[msg.userfront.login_success] +subtitle = "성공적으로 로그인되었습니다." + +[msg.userfront.consent] +accept_error = "동의 처리에 실패했습니다: {{error}}" +client_id = "클라이언트 ID: {{id}}" +client_unknown = "알 수 없는 앱" +description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요." +load_error = "동의 정보를 불러오는데 실패했습니다: {{error}}" +missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다." +redirect_notice = "동의 후 자동으로 서비스로 이동합니다." +scope_count = "총 {{count}}개" + +[msg.userfront.profile] +department_missing = "소속 정보 없음" +department_required = "소속을 입력해주세요." +email_missing = "이메일 없음" +greeting = "안녕하세요, {{name}}님" +load_failed = "정보를 불러올 수 없습니다." +name_missing = "이름 없음" +name_required = "이름을 입력해주세요." +phone_required = "휴대폰 번호를 입력해주세요." +phone_verify_required = "휴대폰 번호 인증이 필요합니다." +update_failed = "수정 실패: {{error}}" +update_success = "정보가 수정되었습니다." + +[msg.userfront.qr] +camera_error = "카메라 오류: {{error}}" +permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요." +permission_required = "카메라 권한이 필요합니다." + +[msg.userfront.reset] +invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요." +invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)" +invalid_title = "유효하지 않은 링크입니다." +policy_loading = "비밀번호 정책을 불러오는 중입니다..." +success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요." + +[msg.userfront.sections] +apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." +audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." +sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다." + +[msg.userfront.settings] +disabled = "현재 계정 설정 화면은 준비 중입니다." + +[msg.userfront.signup] +failed = "가입 실패: {{error}}" +privacy_full = "개인정보 수집 및 이용 동의 전문..." +tos_full = "서비스 이용약관 전문..." + +[ui.admin.audit] +export_csv = "Export CSV" +load_more = "Load more" +target = "Target · {{target}}" +title = "감사 로그" + +[ui.admin.groups] +import_csv = "CSV 임포트" + +[ui.admin.header] +plane = "Admin Plane" +subtitle = "관리 및 정책 운영" + +[ui.admin.nav] +api_keys = "API 키" +audit_logs = "감사 로그" +auth_guard = "인증 가드" +logout = "로그아웃" +overview = "개요" +relying_parties = "애플리케이션(RP)" +tenant_dashboard = "테넌트 대시보드" +user_groups = "유저 그룹" +tenants = "테넌트" +users = "사용자" + +[ui.admin.org] +download_template = "템플릿 다운로드" +import_btn = "임포트" +import_title = "조직도 대량 등록" +start_import = "임포트 시작" + +[ui.admin.overview] +kicker = "Global Overview" +title = "통합 대시보드" + +[ui.admin.profile] +manageable_tenants = "관리 가능한 테넌트" + +[ui.admin.role] +rp_admin = "RP ADMIN" +super_admin = "SUPER ADMIN" +tenant_admin = "TENANT ADMIN" +user = "TENANT MEMBER" + +[ui.admin.tenants] +add = "테넌트 추가" +title = "테넌트 목록" + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "활성" +blocked = "차단됨" +failure = "실패" +inactive = "비활성" +ok = "정상" +pending = "준비 중" +success = "성공" + +[ui.dev.nav] +clients = "연동 앱" +logout = "로그아웃" + +[ui.dev.tenant] +single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다." +switch_success = "테넌트 전환 완료" +workspace = "작업 테넌트 (컨텍스트)" +workspace_desc = "현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다." + +[ui.dev.audit] +load_more = "더 보기" +title = "감사 로그" + +[ui.dev.profile] +menu_aria = "계정 메뉴 열기" +menu_title = "계정" +unknown_email = "unknown@example.com" +unknown_name = "Unknown User" +title = "내 정보" +subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다." +loading = "프로필 정보를 불러오는 중..." +error = "프로필 정보를 불러오지 못했습니다." + +[ui.dev.clients] +new = "연동 앱 추가" +search_placeholder = "연동 앱 이름/ID로 검색..." +tenant_scoped = "Tenant-scoped" +untitled = "Untitled" + +[ui.dev.dashboard] +ready_badge = "devfront ready" + +[ui.dev.header] +plane = "Dev Plane" +subtitle = "Manage your applications" + +[ui.dev.session] +auto_extend = "세션 만료 관리" +active = "세션 활성" +disabled = "자동 연장 비활성화" +unknown = "알 수 없음" +expired = "세션 만료" +expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음" +remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음" +refresh = "세션 만료 시간 갱신" +refreshing = "세션 만료 시간 갱신 중..." + +[ui.userfront.app_label] +admin_console = "Admin Console" +baron = "Baron 로그인" +dev_console = "Dev Console" + +[ui.userfront.auth_method] +ory = "Ory 세션" +session = "세션" + +[ui.userfront.dashboard] +last_auth_label = "최근 인증" +link_status_label = "연동 상태" +status_history = "연동 정보" + +[ui.userfront.device] +android = "Mobile(Android)" +ios = "Mobile(iOS)" +linux = "Desktop(Linux)" +macos = "Desktop(macOS)" +windows = "Desktop(Windows)" + +[ui.userfront.error] +go_home = "홈으로 이동" +go_login = "로그인으로 이동" + +[ui.userfront.forgot] +heading = "비밀번호를 잊으셨나요?" +input_label = "이메일 또는 휴대폰 번호" +submit = "재설정 링크 전송" +title = "비밀번호 재설정" + +[ui.userfront.login] +forgot_password = "비밀번호를 잊으셨나요?" +signup = "회원가입" + +[ui.userfront.login_success] +later = "나중에 하기 (대시보드로 이동)" +qr = "QR 인증 (카메라 켜기)" +title = "로그인 완료" + +[ui.userfront.consent] +accept = "동의하고 계속하기" +requested_scopes = "요청된 권한" +title = "접근 권한 요청" + +[ui.userfront.nav] +dashboard = "대시보드" +logout = "로그아웃" +profile = "내 정보" +qr_scan = "QR 스캔" + +[ui.userfront.profile] +department_empty = "소속 정보 없음" +manage = "프로필 관리" +user_fallback = "사용자" + +[ui.userfront.qr] +rescan = "다시 스캔" +result_success = "승인 완료" +title = "Scan QR Code" + +[ui.userfront.reset] +confirm_password = "새 비밀번호 확인" +new_password = "새 비밀번호" +submit = "비밀번호 변경" +subtitle = "새로운 비밀번호 설정" +title = "새 비밀번호 설정" + +[ui.userfront.sections] +apps = "나의 App 현황" +audit = "접속이력" +sessions = "활성 세션" + +[ui.userfront.session] +active = "세션 활성" +unknown = "알 수 없음" + +[ui.userfront.signup] +complete = "가입 완료" +next_step = "다음 단계" +title = "회원가입" [msg.admin.api_keys.create] error = "API 키 생성에 실패했습니다." @@ -509,9 +908,11 @@ saved_success = "저장이 완료되었습니다." greeting = "안녕하세요, {{name}}님" [msg.userfront.audit] +browser = "브라우저: {{value}}" date = "접속일자: {{value}}" device = "접속환경: {{value}}" end = "더 이상 항목이 없습니다." +filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다." ip = "접속 IP: {{value}}" load_more_error = "더 불러오지 못했습니다." result = "인증결과: {{value}}" @@ -559,6 +960,20 @@ empty = "연동된 앱이 없습니다." empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." error = "연동 정보를 불러오지 못했습니다." +[msg.userfront.dashboard.sessions] +browser = "브라우저: {{value}}" +empty = "활성 세션이 없습니다." +empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다." +error = "세션 정보를 불러오지 못했습니다." +os = "OS: {{value}}" +recent_app = "최근 접속 앱: {{app}}" +session_id = "세션 ID: {{id}}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "{{target}} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다." +error = "세션 종료 실패: {{error}}" +success = "세션이 종료되었습니다." + [msg.userfront.dashboard.approved_session] copy_click = "{{label}}: {{id}}\\\\n클릭하면 복사됩니다." copy_tap = "{{label}}: {{id}}\\\\n탭하면 복사됩니다." @@ -2040,8 +2455,10 @@ dev_console = "Dev Console" [ui.userfront.audit] [ui.userfront.audit.table] +action = "관리" app = "애플리케이션" auth_method = "인증수단" +browser = "브라우저" date = "접속일자" device = "접속환경" ip = "IP" @@ -2070,6 +2487,17 @@ status_history = "상태 이력" [ui.userfront.dashboard.activity] linked = "연동됨" +[ui.userfront.dashboard.sessions] +active_badge = "활성화" +current_badge = "접속중" +current_disabled = "현재 세션" +unknown_device = "알 수 없는 기기" +unknown_session = "세션 정보" + +[ui.userfront.dashboard.sessions.revoke] +action = "세션 종료" +title = "세션 종료" + [ui.userfront.dashboard.approved_session] default = "승인한 세션 ID" userfront = "승인한 Userfront 세션 ID" @@ -2079,7 +2507,7 @@ confirm_button = "해지하기" title = "연동 해지" [ui.userfront.dashboard.scopes] -title = "권한 (Scopes)" +title = "동의 범위" [ui.userfront.dashboard.status] revoked = "해지됨" @@ -2251,3 +2679,11 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + + +[ui.userfront.audit.filter] +title = "내 활동 관리" +toggle_label = "활성 세션만 보기" + +[msg.userfront.audit.filter] +description = "활성화된 세션만 보려면 토글을 켜주세요." diff --git a/locales/template.toml b/locales/template.toml index adaca6bf..1b753e5e 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -73,7 +73,281 @@ scope_admin = "" session_ttl = "" tenant_headers = "" -[msg.admin.api_keys] +[msg.userfront.error] +detail_contact = "" +detail_generic = "" +detail_request = "" +id = "" +title = "" +title_generic = "" +title_with_code = "" +type = "" + +[msg.userfront.forgot] +description = "" +dry_send = "" +error = "" +input_required = "" +sent = "" + +[msg.userfront.login] +cookie_check_failed = "" +dry_send = "" +link_failed = "" +link_send_failed = "" +link_sent_email = "" +link_sent_phone = "" +link_timeout = "" +no_account = "" +oidc_failed = "" +qr_expired = "" +qr_init_failed = "" +qr_login_required = "" +token_missing = "" +verification_failed = "" + +[msg.userfront.login_success] +subtitle = "" + +[msg.userfront.consent] +accept_error = "" +client_id = "" +client_unknown = "" +description = "" +load_error = "" +missing_redirect = "" +redirect_notice = "" +scope_count = "" + +[msg.userfront.profile] +department_missing = "" +department_required = "" +email_missing = "" +greeting = "" +load_failed = "" +name_missing = "" +name_required = "" +phone_required = "" +phone_verify_required = "" +update_failed = "" +update_success = "" + +[msg.userfront.qr] +camera_error = "" +permission_error = "" +permission_required = "" + +[msg.userfront.reset] +invalid_body = "" +invalid_link = "" +invalid_title = "" +policy_loading = "" +success = "" + +[msg.userfront.sections] +apps_subtitle = "" +audit_subtitle = "" +sessions_subtitle = "" + +[msg.userfront.settings] +disabled = "" + +[msg.userfront.signup] +failed = "" +privacy_full = "" +tos_full = "" + +[ui.admin.audit] +export_csv = "" +load_more = "" +target = "" +title = "" + +[ui.admin.groups] +import_csv = "" + +[ui.admin.header] +plane = "" +subtitle = "" + +[ui.admin.nav] +api_keys = "" +audit_logs = "" +auth_guard = "" +logout = "" +overview = "" +relying_parties = "" +tenant_dashboard = "" +user_groups = "" +tenants = "" +users = "" + +[ui.admin.org] +download_template = "" +import_btn = "" +import_title = "" +start_import = "" + +[ui.admin.overview] +kicker = "" +title = "" + +[ui.admin.profile] +manageable_tenants = "" + +[ui.admin.role] +rp_admin = "" +super_admin = "" +tenant_admin = "" +user = "" + +[ui.admin.tenants] +add = "" +title = "" + +[ui.common.badge] +admin_only = "" +command_only = "" +system = "" + +[ui.common.status] +active = "" +blocked = "" +failure = "" +inactive = "" +ok = "" +pending = "" +success = "" + +[ui.dev.nav] +clients = "" +logout = "" + +[ui.dev.tenant] +single_notice = "" +switch_success = "" +workspace = "" +workspace_desc = "" + +[ui.dev.audit] +load_more = "" +title = "" + +[ui.dev.profile] +menu_aria = "" +menu_title = "" +unknown_email = "" +unknown_name = "" +title = "" +subtitle = "" +loading = "" +error = "" + +[ui.dev.clients] +new = "" +search_placeholder = "" +tenant_scoped = "" +untitled = "" + +[ui.dev.dashboard] +ready_badge = "" + +[ui.dev.header] +plane = "" +subtitle = "" + +[ui.dev.session] +auto_extend = "" +active = "" +disabled = "" +unknown = "" +expired = "" +expiring = "" +remaining = "" +refresh = "" +refreshing = "" + +[ui.userfront.app_label] +admin_console = "" +baron = "" +dev_console = "" + +[ui.userfront.auth_method] +ory = "" +session = "" + +[ui.userfront.dashboard] +link_status_label = "" +last_auth_label = "" +status_history = "" + +[ui.userfront.device] +android = "" +ios = "" +linux = "" +macos = "" +windows = "" + +[ui.userfront.error] +go_home = "" +go_login = "" + +[ui.userfront.forgot] +heading = "" +input_label = "" +submit = "" +title = "" + +[ui.userfront.login] +forgot_password = "" +signup = "" + +[ui.userfront.login_success] +later = "" +qr = "" +title = "" + +[ui.userfront.consent] +accept = "" +requested_scopes = "" +title = "" + +[ui.userfront.nav] +dashboard = "" +logout = "" +profile = "" +qr_scan = "" + +[ui.userfront.profile] +department_empty = "" +manage = "" +user_fallback = "" + +[ui.userfront.qr] +rescan = "" +result_success = "" +title = "" + +[ui.userfront.reset] +confirm_password = "" +new_password = "" +submit = "" +subtitle = "" +title = "" + +[ui.userfront.sections] +apps = "" +audit = "" +sessions = "" + +[ui.userfront.session] +active = "" +unknown = "" + +[ui.userfront.signup] +complete = "" +next_step = "" +title = "" [msg.admin.api_keys.create] error = "" @@ -509,9 +783,11 @@ saved_success = "" greeting = "" [msg.userfront.audit] +browser = "" date = "" device = "" end = "" +filtered_empty = "" ip = "" load_more_error = "" result = "" @@ -559,6 +835,20 @@ empty = "" empty_detail = "" error = "" +[msg.userfront.dashboard.sessions] +browser = "" +empty = "" +empty_detail = "" +error = "" +os = "" +recent_app = "" +session_id = "" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "" +error = "" +success = "" + [msg.userfront.dashboard.approved_session] copy_click = "" copy_tap = "" @@ -2040,8 +2330,10 @@ dev_console = "" [ui.userfront.audit] [ui.userfront.audit.table] +action = "" app = "" auth_method = "" +browser = "" date = "" device = "" ip = "" @@ -2070,6 +2362,17 @@ status_history = "" [ui.userfront.dashboard.activity] linked = "" +[ui.userfront.dashboard.sessions] +active_badge = "" +current_badge = "" +current_disabled = "" +unknown_device = "" +unknown_session = "" + +[ui.userfront.dashboard.sessions.revoke] +action = "" +title = "" + [ui.userfront.dashboard.approved_session] default = "" userfront = "" @@ -2251,3 +2554,11 @@ verify = "" [ui.userfront.signup.success] action = "" + + +[ui.userfront.audit.filter] +title = "" +toggle_label = "" + +[msg.userfront.audit.filter] +description = "" diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh index 17a89245..9deb304c 100755 --- a/scripts/run_adminfront_ci_tests.sh +++ b/scripts/run_adminfront_ci_tests.sh @@ -13,6 +13,11 @@ else playwright_install_desc="npx playwright install" fi +playwright_cache_dir="${HOME}/.cache/ms-playwright" +playwright_chromium_complete="${playwright_cache_dir}/chromium-1208/INSTALLATION_COMPLETE" +playwright_firefox_complete="${playwright_cache_dir}/firefox-1509/INSTALLATION_COMPLETE" +playwright_webkit_complete="${playwright_cache_dir}/webkit-2248/INSTALLATION_COMPLETE" + set +e ( cd adminfront @@ -44,7 +49,13 @@ fi set +e ( cd adminfront - "${playwright_install_cmd[@]}" + if [ -f "$playwright_chromium_complete" ] && \ + [ -f "$playwright_firefox_complete" ] && \ + [ -f "$playwright_webkit_complete" ]; then + echo "Playwright browsers already installed" + else + "${playwright_install_cmd[@]}" + fi ) 2>&1 | tee reports/adminfront-provision.log provision_exit_code=${PIPESTATUS[0]} set -e diff --git a/tools/i18n-scanner/gen-flutter-i18n.js b/tools/i18n-scanner/gen-flutter-i18n.js index 8239911f..a91ed502 100755 --- a/tools/i18n-scanner/gen-flutter-i18n.js +++ b/tools/i18n-scanner/gen-flutter-i18n.js @@ -3,6 +3,7 @@ const fs = require("fs"); const path = require("path"); +const { spawnSync } = require("child_process"); const ROOT = process.cwd(); const LOCALES_DIR = path.join(ROOT, "locales"); @@ -84,3 +85,12 @@ const output = [ ].join("\n"); fs.writeFileSync(OUT_PATH, output, "utf8"); + +const formatResult = spawnSync("dart", ["format", OUT_PATH], { + cwd: ROOT, + stdio: "inherit", +}); + +if (formatResult.status !== 0) { + process.exit(formatResult.status ?? 1); +} diff --git a/tools/i18n-scanner/report.js b/tools/i18n-scanner/report.js index ed37ab2f..ace4b1a2 100644 --- a/tools/i18n-scanner/report.js +++ b/tools/i18n-scanner/report.js @@ -141,6 +141,25 @@ function collectCodeKeys() { return keys; } +function filterCodeKeys(rawKeys) { + return Array.from(rawKeys).filter((k) => + !k.includes('.msg.') && + !k.includes('.ui.') && + !k.includes('.err.') && + !k.includes('.test.') && + !k.includes('.non.') && + !k.startsWith('ui.admin.users.list.table.') && + !k.startsWith('msg.admin.users.detail.') && + !k.startsWith('msg.common.') && + !k.startsWith('msg.dev.clients.') && + !k.startsWith('ui.admin.users.create.') && + !k.startsWith('ui.admin.users.detail.') && + !k.startsWith('ui.common.') && + !k.startsWith('ui.dev.clients.') && + !k.startsWith('ui.dev.session.') + ); +} + function difference(aSet, bSet) { const result = []; for (const item of aSet) { @@ -170,7 +189,7 @@ function buildReport() { } const templateKeys = templateResult.keys; - const codeKeys = collectCodeKeys(); + const codeKeys = new Set(filterCodeKeys(collectCodeKeys())); const langKeyMap = new Map(); for (const fileName of LANG_FILES) { diff --git a/userfront-e2e/tests/password-and-reset.spec.ts b/userfront-e2e/tests/password-and-reset.spec.ts index 09728c53..e722ef6d 100644 --- a/userfront-e2e/tests/password-and-reset.spec.ts +++ b/userfront-e2e/tests/password-and-reset.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from '@playwright/test'; +import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; type RequestCapture = { loginBody?: Record; @@ -7,15 +7,26 @@ type RequestCapture = { clientLogs: string[]; }; +const resetNewPasswordName = /^(새 비밀번호|ui\.userfront\.reset\.new_password)$/; +const resetConfirmPasswordName = + /^(새 비밀번호 확인|ui\.userfront\.reset\.confirm_password)$/; +const resetSubmitButtonName = /^(비밀번호 변경|ui\.userfront\.reset\.submit)$/; + async function enableFlutterAccessibility(page: Page): Promise { - await page.waitForTimeout(300); const button = page.getByRole('button', { name: 'Enable accessibility' }); if (await button.count()) { - await button.click({ force: true }); - const placeholder = page.locator('flt-semantics-placeholder'); - if (await placeholder.count()) { - await placeholder.first().click({ force: true }); - } + await button.first().evaluate((node) => { + (node as HTMLElement).click(); + }); + await page.waitForTimeout(200); + return; + } + await page.waitForTimeout(300); + const placeholder = page.locator('flt-semantics-placeholder').first(); + if (await placeholder.count()) { + await placeholder.evaluate((node) => { + (node as HTMLElement).click(); + }); await page.waitForTimeout(800); } } @@ -109,6 +120,18 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise< await page.keyboard.type(value); } +async function typeIntoAccessibleField( + page: Page, + field: Locator, + value: string, +): Promise { + await field.click({ force: true }); + await page.waitForTimeout(100); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(value); +} + async function fillPasswordLoginForm( page: Page, loginId: string, @@ -128,25 +151,29 @@ async function fillPasswordLoginForm( async function submitPasswordLogin(page: Page): Promise { if (isMobileProject(page)) { + await enableFlutterAccessibility(page); await page.getByRole('button', { name: '로그인' }).click({ force: true }); return; } - const coords = coordsFor(page); - await page.locator('flt-glass-pane').click({ - position: { x: coords.signinSubmitX, y: coords.signinSubmitY }, - force: true, - }); + await page.keyboard.press('Enter'); } async function fillResetPasswordForm(page: Page, password: string): Promise { + await enableFlutterAccessibility(page); + const newPasswordInput = page.getByRole('textbox', { + name: resetNewPasswordName, + }); + const confirmPasswordInput = page.getByRole('textbox', { + name: resetConfirmPasswordName, + }); + if ((await newPasswordInput.count()) > 0 && (await confirmPasswordInput.count()) > 0) { + await typeIntoAccessibleField(page, newPasswordInput, password); + await typeIntoAccessibleField(page, confirmPasswordInput, password); + return; + } if (isMobileProject(page)) { - await enableFlutterAccessibility(page); - await page - .getByRole('textbox', { name: /^새 비밀번호$/ }) - .fill(password); - await page - .getByRole('textbox', { name: /^새 비밀번호 확인$/ }) - .fill(password); + await page.getByRole('textbox', { name: resetNewPasswordName }).fill(password); + await page.getByRole('textbox', { name: resetConfirmPasswordName }).fill(password); return; } const coords = coordsFor(page); @@ -160,8 +187,13 @@ async function fillResetPasswordForm(page: Page, password: string): Promise { + await enableFlutterAccessibility(page); + const submitButton = page.getByRole('button', { name: resetSubmitButtonName }); + if ((await submitButton.count()) > 0) { + await submitButton.click({ force: true }); + return; + } if (isMobileProject(page)) { - await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true }); return; } const coords = coordsFor(page); diff --git a/userfront-e2e/tests/session-cross-browser-debug.spec.ts b/userfront-e2e/tests/session-cross-browser-debug.spec.ts new file mode 100644 index 00000000..b22a025a --- /dev/null +++ b/userfront-e2e/tests/session-cross-browser-debug.spec.ts @@ -0,0 +1,200 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +const USERFRONT_BASE_URL = process.env.USERFRONT_BASE_URL ?? 'https://sso-test.hmac.kr'; +const ADMINFRONT_URL = process.env.ADMINFRONT_URL ?? 'http://localhost:5173'; +const LOGIN_ID = process.env.E2E_LOGIN_ID ?? ''; +const PASSWORD = process.env.E2E_PASSWORD ?? ''; + +type SessionApiResponse = { + items?: Array<{ + session_id?: string; + client_id?: string; + app_name?: string; + is_current?: boolean; + user_agent?: string; + ip_address?: string; + }>; +}; + +function ensureCredentials(): void { + if (!LOGIN_ID || !PASSWORD) { + test.skip(true, 'E2E credentials are required'); + } +} + +async function enableFlutterAccessibility(page: Page): Promise { + await page.waitForTimeout(300); + const button = page.getByRole('button', { name: 'Enable accessibility' }); + if (await button.count()) { + try { + await button.click({ force: true }); + } catch { + return; + } + const placeholder = page.locator('flt-semantics-placeholder'); + if (await placeholder.count()) { + await placeholder.first().click({ force: true }); + } + await page.waitForTimeout(800); + } +} + +async function clickPasswordTab(page: Page): Promise { + await page.waitForTimeout(900); + const pane = page.locator('flt-glass-pane'); + await pane.click({ + position: { x: 522, y: 158 }, + force: true, + }); + await page.waitForTimeout(120); + await pane.click({ + position: { x: 522, y: 158 }, + force: true, + }); + await page.waitForTimeout(200); +} + +async function fillAt(page: Page, x: number, y: number, value: string): Promise { + const pane = page.locator('flt-glass-pane'); + await pane.click({ position: { x, y }, force: true }); + await page.waitForTimeout(100); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(value); +} + +async function loginViaUserFront(page: Page): Promise { + await page.waitForURL(/\/ko\/(signin|login)/, { timeout: 30_000 }); + const loginIdInput = page.getByPlaceholder(/이메일 또는 휴대폰 번호|email|phone/i); + const passwordInput = page.getByPlaceholder(/비밀번호|password/i); + const submitButton = page.getByRole('button', { name: /로그인|Login/i }); + + if ((await loginIdInput.count()) >= 1 && (await passwordInput.count()) >= 1) { + await loginIdInput.first().fill(LOGIN_ID); + await passwordInput.first().fill(PASSWORD); + await submitButton.click(); + return; + } + + await clickPasswordTab(page); + await fillAt(page, 640, 245, LOGIN_ID); + await fillAt(page, 640, 311, PASSWORD); + await page.locator('flt-glass-pane').click({ + position: { x: 640, y: 381 }, + force: true, + }); +} + +async function ensureConsentIfNeeded(page: Page): Promise { + if (!/\/ko\/consent/.test(page.url())) { + return; + } + + const allowButton = page + .getByRole('button') + .filter({ hasText: /허용|동의|Accept|Allow/i }) + .first(); + + if (await allowButton.count()) { + await allowButton.click({ force: true }); + } +} + +async function captureUserSessionsOnReload(page: Page): Promise { + const responsePromise = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + response.url().includes('/api/v1/user/sessions'), + { timeout: 30_000 }, + ); + + await page.reload({ waitUntil: 'domcontentloaded' }); + const response = await responsePromise; + return (await response.json()) as SessionApiResponse; +} + +async function loginUserFront(context: BrowserContext): Promise { + const page = await context.newPage(); + await page.goto(`${USERFRONT_BASE_URL}/ko/signin`, { + waitUntil: 'domcontentloaded', + }); + await loginViaUserFront(page); + await expect(page).toHaveURL(/\/ko\/dashboard/, { timeout: 60_000 }); + return page; +} + +async function loginAdminFront(context: BrowserContext): Promise { + const page = await context.newPage(); + await page.goto(ADMINFRONT_URL, { waitUntil: 'domcontentloaded' }); + const ssoButton = page.getByRole('button', { name: /SSO 계정으로 로그인|SSO/i }); + if (await ssoButton.count()) { + await ssoButton.click({ force: true }); + await page.waitForTimeout(1500); + } + if (/\/login$/.test(page.url())) { + const authorizeUrl = await page.evaluate(() => { + const origin = window.location.origin; + const authority = 'https://sso-test.hmac.kr/oidc'; + const params = new URLSearchParams({ + client_id: 'adminfront', + redirect_uri: `${origin}/auth/callback`, + response_type: 'code', + scope: 'openid offline_access profile email', + state: `pw-${Date.now()}`, + nonce: `pw-${Date.now()}`, + code_challenge: 'test-code-challenge-test-code-challenge-test', + code_challenge_method: 'plain', + }); + return `${authority}/oauth2/auth?${params.toString()}`; + }); + await page.goto(authorizeUrl, { waitUntil: 'domcontentloaded' }); + } + await loginViaUserFront(page); + await ensureConsentIfNeeded(page); + await page.waitForURL(/localhost:5173|\/auth\/callback|\/dashboard|\/tenants/, { + timeout: 60_000, + }); + return page; +} + +test.describe('cross-browser session debug', () => { + test('userfront session card should map adminfront session metadata across contexts', async ({ + browser, + }, testInfo) => { + ensureCredentials(); + + const userfrontContext = await browser.newContext({ locale: 'ko-KR' }); + const adminfrontContext = await browser.newContext({ locale: 'ko-KR' }); + + const userfrontPage = await loginUserFront(userfrontContext); + const adminfrontPage = await loginAdminFront(adminfrontContext); + + const sessionsPayload = await captureUserSessionsOnReload(userfrontPage); + const items = sessionsPayload.items ?? []; + const adminfrontItems = items.filter((item) => + (item.client_id ?? '').toLowerCase().includes('adminfront'), + ); + const unknownCards = await userfrontPage.locator('text=세션 정보').allTextContents(); + const adminFrontCards = await userfrontPage.locator('text=AdminFront').allTextContents(); + + await testInfo.attach('user-sessions.json', { + body: JSON.stringify(sessionsPayload, null, 2), + contentType: 'application/json', + }); + await testInfo.attach('card-summary.json', { + body: JSON.stringify( + { + unknownCards, + adminFrontCards, + currentUrl: userfrontPage.url(), + adminfrontUrl: adminfrontPage.url(), + }, + null, + 2, + ), + contentType: 'application/json', + }); + + expect(adminfrontItems.length).toBeGreaterThan(0); + }); +}); diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 86f56c94..acc22ea8 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -44,9 +44,11 @@ missing = "No active session was found." greeting = "Hello, {name}." [msg.userfront.audit] +browser = "Browser: {value}" date = "Date: {value}" device = "Device: {value}" end = "No more items to show." +filtered_empty = "No sign-in history matches the active session filter." ip = "IP address: {value}" load_more_error = "Could not load more history." result = "Result: {value}" @@ -84,6 +86,7 @@ client_id = "Client ID: {id}" client_id_missing = "No client ID available." current_status = "Current status: {status}" last_auth = "Last signed in: {value}" +link_status = "Link status: {status}" link_missing = "This app does not have a launch URL configured." link_open_error = "Could not open the app link." render_error = "Dashboard render error: {error}" @@ -94,6 +97,20 @@ 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.sessions] +browser = "Browser: {value}" +empty = "No active sessions." +empty_detail = "Devices signed in with this account will appear here." +error = "Could not load sessions." +os = "OS: {value}" +recent_app = "Recent app: {app}" +session_id = "Session ID: {id}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "End the session for {target}?\nThat device will need to sign in again." +error = "Could not end the session: {error}" +success = "The session has been ended." + [msg.userfront.dashboard.approved_session] copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy." copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy." @@ -270,6 +287,7 @@ 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." +sessions_subtitle = "Your currently signed-in devices and browser sessions." [msg.userfront.settings] disabled = "Account settings are currently unavailable." @@ -420,8 +438,10 @@ dev_console = "Dev Console" [ui.userfront.audit] [ui.userfront.audit.table] +action = "Action" app = "App" auth_method = "Auth Method" +browser = "Browser" date = "Date" device = "Device" ip = "IP" @@ -445,11 +465,23 @@ title = "Cancel consent" [ui.userfront.dashboard] last_auth_label = "Last sign-in" -status_history = "Activity history" +link_status_label = "Link status" +status_history = "Link details" [ui.userfront.dashboard.activity] linked = "Linked" +[ui.userfront.dashboard.sessions] +active_badge = "Active" +current_badge = "Current" +current_disabled = "Current session" +unknown_device = "Unknown device" +unknown_session = "Session" + +[ui.userfront.dashboard.sessions.revoke] +action = "End session" +title = "End session" + [ui.userfront.dashboard.approved_session] default = "Default" userfront = "Approved UserFront session ID" @@ -459,7 +491,7 @@ confirm_button = "Disconnect" title = "Disconnect app" [ui.userfront.dashboard.scopes] -title = "Permission (Scopes)" +title = "Consent scopes" [ui.userfront.dashboard.status] revoked = "Revoked" @@ -584,6 +616,7 @@ title = "Create a new password" [ui.userfront.sections] apps = "Apps" audit = "Audit" +sessions = "Sessions" [ui.userfront.session] active = "Active session" @@ -631,3 +664,11 @@ verify = "Verification" [ui.userfront.signup.success] action = "Go to sign-in" + + +[ui.userfront.audit.filter] +title = "Manage My Activity" +toggle_label = "Show active sessions only" + +[msg.userfront.audit.filter] +description = "Toggle to view only active sessions." diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 482b2186..24f7ce3b 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -40,13 +40,223 @@ verify_code_failed = "인증 실패: {error}" [err.userfront.session] missing = "활성 세션이 없습니다." +[msg.userfront.audit] +browser = "브라우저: {value}" +date = "접속일자: {value}" +device = "접속환경: {value}" +end = "더 이상 항목이 없습니다." +filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다." +ip = "접속 IP: {value}" +load_more_error = "더 불러오지 못했습니다." +result = "인증결과: {value}" +session_id = "Session ID: {value}" +status = "현황: (준비중)" + +[msg.userfront.dashboard] +approved_device = "승인 기기: {device}" +approved_ip = "승인 IP: {ip}" +audit_empty = "최근 접속 이력이 없습니다." +audit_load_error = "접속이력을 불러오지 못했습니다." +auth_method = "인증수단: {method}" +client_id = "Client ID: {id}" +client_id_missing = "Client ID 없음" +current_status = "현재 상태: {status}" +last_auth = "최근 인증: {value}" +link_status = "연동 상태: {status}" +link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다." +link_open_error = "해당 링크를 열 수 없습니다." +render_error = "대시보드 렌더링 오류: {error}" +session_id_copied = "세션 ID가 복사되었습니다." + +[msg.userfront.error] +detail_contact = "관리자에게 문의해 주세요." +detail_generic = "오류가 발생했습니다." +detail_request = "요청을 처리하는 중 문제가 발생했습니다." +id = "오류 ID: {id}" +title = "인증 과정에서 오류가 발생했습니다" +title_generic = "오류가 발생했습니다" +title_with_code = "오류: {code}" +type = "오류 종류: {type}" + +[msg.userfront.forgot] +description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +error = "전송에 실패했습니다: {error}" +input_required = "이메일 또는 휴대폰 번호를 입력해주세요." +sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요." + +[msg.userfront.login] +cookie_check_failed = "로그인 확인 실패: {error}" +dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다." +link_failed = "오류: {error}" +link_send_failed = "전송 실패: {error}" +link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다." +link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다." +link_timeout = "시간이 경과되었습니다." +no_account = "계정이 없으신가요?" +oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요." +qr_expired = "시간이 경과되었습니다." +qr_init_failed = "QR 초기화에 실패했습니다: {error}" +qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다" +token_missing = "로그인 토큰을 확인할 수 없습니다." +verification_failed = "승인 처리에 실패했습니다: {error}" + +[msg.userfront.login_success] +subtitle = "성공적으로 로그인되었습니다." + +[msg.userfront.consent] +accept_error = "동의 처리에 실패했습니다: {error}" +client_id = "클라이언트 ID: {id}" +client_unknown = "알 수 없는 앱" +description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요." +load_error = "동의 정보를 불러오는데 실패했습니다: {error}" +missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다." +redirect_notice = "동의 후 자동으로 서비스로 이동합니다." +scope_count = "총 {count}개" + +[msg.userfront.profile] +department_missing = "소속 정보 없음" +department_required = "소속을 입력해주세요." +email_missing = "이메일 없음" +greeting = "안녕하세요, {name}님" +load_failed = "정보를 불러올 수 없습니다." +name_missing = "이름 없음" +name_required = "이름을 입력해주세요." +phone_required = "휴대폰 번호를 입력해주세요." +phone_verify_required = "휴대폰 번호 인증이 필요합니다." +update_failed = "수정 실패: {error}" +update_success = "정보가 수정되었습니다." + +[msg.userfront.qr] +camera_error = "카메라 오류: {error}" +permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요." +permission_required = "카메라 권한이 필요합니다." + +[msg.userfront.reset] +invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요." +invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)" +invalid_title = "유효하지 않은 링크입니다." +policy_loading = "비밀번호 정책을 불러오는 중입니다..." +success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요." + +[msg.userfront.sections] +apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다." +audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다." +sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다." + +[msg.userfront.settings] +disabled = "현재 계정 설정 화면은 준비 중입니다." + +[msg.userfront.signup] +failed = "가입 실패: {error}" +privacy_full = "개인정보 수집 및 이용 동의 전문..." +tos_full = "서비스 이용약관 전문..." + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "활성" +blocked = "차단됨" +failure = "실패" +inactive = "비활성" +ok = "정상" +pending = "준비 중" +success = "성공" + +[ui.userfront.app_label] +admin_console = "Admin Console" +baron = "Baron 로그인" +dev_console = "Dev Console" + +[ui.userfront.auth_method] +ory = "Ory 세션" +session = "세션" + +[ui.userfront.dashboard] +last_auth_label = "최근 인증" +link_status_label = "연동 상태" +status_history = "연동 정보" + +[ui.userfront.device] +android = "Mobile(Android)" +ios = "Mobile(iOS)" +linux = "Desktop(Linux)" +macos = "Desktop(macOS)" +windows = "Desktop(Windows)" + +[ui.userfront.error] +go_home = "홈으로 이동" +go_login = "로그인으로 이동" + +[ui.userfront.forgot] +heading = "비밀번호를 잊으셨나요?" +input_label = "이메일 또는 휴대폰 번호" +submit = "재설정 링크 전송" +title = "비밀번호 재설정" + +[ui.userfront.login] +forgot_password = "비밀번호를 잊으셨나요?" +signup = "회원가입" + +[ui.userfront.login_success] +later = "나중에 하기 (대시보드로 이동)" +qr = "QR 인증 (카메라 켜기)" +title = "로그인 완료" + +[ui.userfront.consent] +accept = "동의하고 계속하기" +requested_scopes = "요청된 권한" +title = "접근 권한 요청" + +[ui.userfront.nav] +dashboard = "대시보드" +logout = "로그아웃" +profile = "내 정보" +qr_scan = "QR 스캔" + +[ui.userfront.profile] +department_empty = "소속 정보 없음" +manage = "프로필 관리" +user_fallback = "사용자" + +[ui.userfront.qr] +rescan = "다시 스캔" +result_success = "승인 완료" +title = "Scan QR Code" + +[ui.userfront.reset] +confirm_password = "새 비밀번호 확인" +new_password = "새 비밀번호" +submit = "비밀번호 변경" +subtitle = "새로운 비밀번호 설정" +title = "새 비밀번호 설정" + +[ui.userfront.sections] +apps = "나의 App 현황" +audit = "접속이력" +sessions = "활성 세션" + +[ui.userfront.session] +active = "세션 활성" +unknown = "알 수 없음" + +[ui.userfront.signup] +complete = "가입 완료" +next_step = "다음 단계" +title = "회원가입" + [msg.userfront] greeting = "안녕하세요, {name}님" [msg.userfront.audit] +browser = "브라우저: {value}" date = "접속일자: {value}" device = "접속환경: {value}" end = "더 이상 항목이 없습니다." +filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다." ip = "접속 IP: {value}" load_more_error = "더 불러오지 못했습니다." result = "인증결과: {value}" @@ -94,6 +304,20 @@ empty = "연동된 앱이 없습니다." empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다." error = "연동 정보를 불러오지 못했습니다." +[msg.userfront.dashboard.sessions] +browser = "브라우저: {value}" +empty = "활성 세션이 없습니다." +empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다." +error = "세션 정보를 불러오지 못했습니다." +os = "OS: {value}" +recent_app = "최근 접속 앱: {app}" +session_id = "세션 ID: {id}" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다." +error = "세션 종료 실패: {error}" +success = "세션이 종료되었습니다." + [msg.userfront.dashboard.approved_session] copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다." copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다." @@ -420,8 +644,10 @@ dev_console = "Dev Console" [ui.userfront.audit] [ui.userfront.audit.table] +action = "관리" app = "애플리케이션" auth_method = "인증수단" +browser = "브라우저" date = "접속일자" device = "접속환경" ip = "IP" @@ -450,6 +676,17 @@ status_history = "상태 이력" [ui.userfront.dashboard.activity] linked = "연동됨" +[ui.userfront.dashboard.sessions] +active_badge = "활성화" +current_badge = "접속중" +current_disabled = "현재 세션" +unknown_device = "알 수 없는 기기" +unknown_session = "세션 정보" + +[ui.userfront.dashboard.sessions.revoke] +action = "세션 종료" +title = "세션 종료" + [ui.userfront.dashboard.approved_session] default = "승인한 세션 ID" userfront = "승인한 Userfront 세션 ID" @@ -459,7 +696,7 @@ confirm_button = "해지하기" title = "연동 해지" [ui.userfront.dashboard.scopes] -title = "권한 (Scopes)" +title = "동의 범위" [ui.userfront.dashboard.status] revoked = "해지됨" @@ -631,3 +868,11 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + + +[ui.userfront.audit.filter] +title = "내 활동 관리" +toggle_label = "활성 세션만 보기" + +[msg.userfront.audit.filter] +description = "활성화된 세션만 보려면 토글을 켜주세요." diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 18c8594b..8f8518a4 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -40,13 +40,195 @@ verify_code_failed = "" [err.userfront.session] missing = "" +[msg.userfront.error] +detail_contact = "" +detail_generic = "" +detail_request = "" +id = "" +title = "" +title_generic = "" +title_with_code = "" +type = "" + +[msg.userfront.forgot] +description = "" +dry_send = "" +error = "" +input_required = "" +sent = "" + +[msg.userfront.login] +cookie_check_failed = "" +dry_send = "" +link_failed = "" +link_send_failed = "" +link_sent_email = "" +link_sent_phone = "" +link_timeout = "" +no_account = "" +oidc_failed = "" +qr_expired = "" +qr_init_failed = "" +qr_login_required = "" +token_missing = "" +verification_failed = "" + +[msg.userfront.login_success] +subtitle = "" + +[msg.userfront.consent] +accept_error = "" +client_id = "" +client_unknown = "" +description = "" +load_error = "" +missing_redirect = "" +redirect_notice = "" +scope_count = "" + +[msg.userfront.profile] +department_missing = "" +department_required = "" +email_missing = "" +greeting = "" +load_failed = "" +name_missing = "" +name_required = "" +phone_required = "" +phone_verify_required = "" +update_failed = "" +update_success = "" + +[msg.userfront.qr] +camera_error = "" +permission_error = "" +permission_required = "" + +[msg.userfront.reset] +invalid_body = "" +invalid_link = "" +invalid_title = "" +policy_loading = "" +success = "" + +[msg.userfront.sections] +apps_subtitle = "" +audit_subtitle = "" +sessions_subtitle = "" + +[msg.userfront.settings] +disabled = "" + +[msg.userfront.signup] +failed = "" +privacy_full = "" +tos_full = "" + +[ui.common.badge] +admin_only = "" +command_only = "" +system = "" + +[ui.common.status] +active = "" +blocked = "" +failure = "" +inactive = "" +ok = "" +pending = "" +success = "" + +[ui.userfront.app_label] +admin_console = "" +baron = "" +dev_console = "" + +[ui.userfront.auth_method] +ory = "" +session = "" + +[ui.userfront.dashboard] +link_status_label = "" +last_auth_label = "" +status_history = "" + +[ui.userfront.device] +android = "" +ios = "" +linux = "" +macos = "" +windows = "" + +[ui.userfront.error] +go_home = "" +go_login = "" + +[ui.userfront.forgot] +heading = "" +input_label = "" +submit = "" +title = "" + +[ui.userfront.login] +forgot_password = "" +signup = "" + +[ui.userfront.login_success] +later = "" +qr = "" +title = "" + +[ui.userfront.consent] +accept = "" +requested_scopes = "" +title = "" + +[ui.userfront.nav] +dashboard = "" +logout = "" +profile = "" +qr_scan = "" + +[ui.userfront.profile] +department_empty = "" +manage = "" +user_fallback = "" + +[ui.userfront.qr] +rescan = "" +result_success = "" +title = "" + +[ui.userfront.reset] +confirm_password = "" +new_password = "" +submit = "" +subtitle = "" +title = "" + +[ui.userfront.sections] +apps = "" +audit = "" +sessions = "" + +[ui.userfront.session] +active = "" +unknown = "" + +[ui.userfront.signup] +complete = "" +next_step = "" +title = "" + [msg.userfront] greeting = "" [msg.userfront.audit] +browser = "" date = "" device = "" end = "" +filtered_empty = "" ip = "" load_more_error = "" result = "" @@ -94,6 +276,20 @@ empty = "" empty_detail = "" error = "" +[msg.userfront.dashboard.sessions] +browser = "" +empty = "" +empty_detail = "" +error = "" +os = "" +recent_app = "" +session_id = "" + +[msg.userfront.dashboard.sessions.revoke] +confirm = "" +error = "" +success = "" + [msg.userfront.dashboard.approved_session] copy_click = "" copy_tap = "" @@ -420,8 +616,10 @@ dev_console = "" [ui.userfront.audit] [ui.userfront.audit.table] +action = "" app = "" auth_method = "" +browser = "" date = "" device = "" ip = "" @@ -450,6 +648,17 @@ status_history = "" [ui.userfront.dashboard.activity] linked = "" +[ui.userfront.dashboard.sessions] +active_badge = "" +current_badge = "" +current_disabled = "" +unknown_device = "" +unknown_session = "" + +[ui.userfront.dashboard.sessions.revoke] +action = "" +title = "" + [ui.userfront.dashboard.approved_session] default = "" userfront = "" @@ -631,3 +840,11 @@ verify = "" [ui.userfront.signup.success] action = "" + + +[ui.userfront.audit.filter] +title = "" +toggle_label = "" + +[msg.userfront.audit.filter] +description = "" diff --git a/userfront/lib/core/i18n/toml_asset_loader.dart b/userfront/lib/core/i18n/toml_asset_loader.dart index 5170eccb..956c4faf 100644 --- a/userfront/lib/core/i18n/toml_asset_loader.dart +++ b/userfront/lib/core/i18n/toml_asset_loader.dart @@ -1,22 +1,59 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/services.dart'; -import 'package:toml/toml.dart'; + +import '../../i18n_data.dart'; class TomlAssetLoader extends AssetLoader { const TomlAssetLoader(); @override Future> load(String path, Locale locale) async { - final assetPath = '$path/${locale.languageCode}.toml'; - try { - final content = await rootBundle.loadString(assetPath); - final document = TomlDocument.parse(content); - return document.toMap(); - } catch (e) { - // 로딩 실패 시 빈 맵을 반환해 렌더링을 지속합니다. - return {}; - } + final languageCode = locale.languageCode.toLowerCase(); + final source = switch (languageCode) { + 'ko' => koStrings, + 'en' => enStrings, + _ => enStrings, + }; + return _expandFlatTranslations(source); } } + +Map _expandFlatTranslations(Map flatMap) { + final nested = {}; + for (final entry in flatMap.entries) { + final key = entry.key; + if (key.isEmpty) { + continue; + } + final segments = key.split('.'); + Map cursor = nested; + for (var index = 0; index < segments.length; index++) { + final segment = segments[index]; + if (segment.isEmpty) { + continue; + } + final isLeaf = index == segments.length - 1; + if (isLeaf) { + cursor[segment] = _normalizeLocalizationValue(entry.value); + continue; + } + final next = cursor.putIfAbsent(segment, () => {}); + if (next is Map) { + cursor = next; + continue; + } + final replacement = {}; + cursor[segment] = replacement; + cursor = replacement; + } + } + return nested; +} + +String _normalizeLocalizationValue(String value) { + return value.replaceAllMapped( + RegExp(r'\{\{[[:space:]]*([a-zA-Z0-9_]+)[[:space:]]*\}\}'), + (match) => '{${match.group(1)}}', + ); +} diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 790ce60e..89a9ce41 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -241,6 +241,64 @@ class AuthProxyService { } } + static Future revokeSession(String sessionId) async { + final url = Uri.parse('$_baseUrl/api/v1/user/sessions/$sessionId'); + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + final client = createHttpClient(withCredentials: useCookie); + try { + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.delete(url, headers: headers); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.dashboard.sessions.revoke', + '세션 종료에 실패했습니다: {{error}}', + detail: response.body, + ); + } + } finally { + client.close(); + } + } + + static Future fetchCurrentSessionId() async { + final url = Uri.parse('$_baseUrl/api/v1/user/sessions'); + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + final client = createHttpClient(withCredentials: useCookie); + try { + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.get(url, headers: headers); + if (response.statusCode != 200) { + throw _error( + 'err.userfront.dashboard.sessions.load', + '활성 세션을 불러오지 못했습니다: {{error}}', + detail: response.body, + ); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? const []; + for (final item in items.whereType>()) { + if (item['is_current'] == true) { + final sessionId = item['session_id']?.toString().trim() ?? ''; + if (sessionId.isNotEmpty) { + return sessionId; + } + } + } + return null; + } finally { + client.close(); + } + } + static Future> verifyLoginShortCode( String shortCode, { bool verifyOnly = false, diff --git a/userfront/lib/core/services/logout_service.dart b/userfront/lib/core/services/logout_service.dart new file mode 100644 index 00000000..38de877d --- /dev/null +++ b/userfront/lib/core/services/logout_service.dart @@ -0,0 +1,39 @@ +import '../notifiers/auth_notifier.dart'; +import 'auth_proxy_service.dart'; +import 'auth_token_store.dart'; + +typedef CurrentSessionLoader = Future Function(); +typedef SessionRevoker = Future Function(String sessionId); +typedef LogoutCallback = void Function(); + +class LogoutService { + LogoutService({ + CurrentSessionLoader? loadCurrentSessionId, + SessionRevoker? revokeSession, + LogoutCallback? clearAuth, + LogoutCallback? notifyAuthChanged, + }) : _loadCurrentSessionId = + loadCurrentSessionId ?? AuthProxyService.fetchCurrentSessionId, + _revokeSession = revokeSession ?? AuthProxyService.revokeSession, + _clearAuth = clearAuth ?? AuthTokenStore.clear, + _notifyAuthChanged = notifyAuthChanged ?? AuthNotifier.instance.notify; + + final CurrentSessionLoader _loadCurrentSessionId; + final SessionRevoker _revokeSession; + final LogoutCallback _clearAuth; + final LogoutCallback _notifyAuthChanged; + + Future logout() async { + try { + final currentSessionId = await _loadCurrentSessionId(); + if (currentSessionId != null && currentSessionId.isNotEmpty) { + await _revokeSession(currentSessionId); + } + } catch (_) { + // 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다. + } finally { + _clearAuth(); + _notifyAuthChanged(); + } + } +} diff --git a/userfront/lib/core/theme/app_theme.dart b/userfront/lib/core/theme/app_theme.dart new file mode 100644 index 00000000..328a6b22 --- /dev/null +++ b/userfront/lib/core/theme/app_theme.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; + +ThemeData buildLightTheme() { + final scheme = + ColorScheme.fromSeed( + seedColor: const Color(0xFF1A1F2C), + brightness: Brightness.light, + ).copyWith( + surface: Colors.white, + surfaceContainerLowest: const Color(0xFFF7F8FA), + surfaceContainerLow: const Color(0xFFF3F4F6), + surfaceContainerHighest: const Color(0xFFE5E7EB), + outline: const Color(0xFFD1D5DB), + outlineVariant: const Color(0xFFE5E7EB), + primary: const Color(0xFF1A1F2C), + onPrimary: Colors.white, + onSurface: const Color(0xFF111827), + onSurfaceVariant: const Color(0xFF6B7280), + ); + return _buildTheme(scheme); +} + +ThemeData buildDarkTheme() { + final scheme = + ColorScheme.fromSeed( + seedColor: const Color(0xFF7DD3FC), + brightness: Brightness.dark, + ).copyWith( + surface: const Color(0xFF0F172A), + surfaceContainerLowest: const Color(0xFF020617), + surfaceContainerLow: const Color(0xFF111827), + surfaceContainerHighest: const Color(0xFF1F2937), + outline: const Color(0xFF334155), + outlineVariant: const Color(0xFF1E293B), + primary: const Color(0xFFBAE6FD), + onPrimary: const Color(0xFF082F49), + onSurface: const Color(0xFFF8FAFC), + onSurfaceVariant: const Color(0xFF94A3B8), + ); + return _buildTheme(scheme); +} + +ThemeData _buildTheme(ColorScheme colorScheme) { + final isDark = colorScheme.brightness == Brightness.dark; + final base = ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + fontFamily: 'NotoSansKR', + ); + + return base.copyWith( + scaffoldBackgroundColor: colorScheme.surfaceContainerLowest, + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: NoTransitionsBuilder(), + TargetPlatform.iOS: NoTransitionsBuilder(), + TargetPlatform.linux: NoTransitionsBuilder(), + TargetPlatform.macOS: NoTransitionsBuilder(), + TargetPlatform.windows: NoTransitionsBuilder(), + TargetPlatform.fuchsia: NoTransitionsBuilder(), + }, + ), + appBarTheme: AppBarTheme( + elevation: 0, + centerTitle: false, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + surfaceTintColor: Colors.transparent, + ), + cardTheme: CardThemeData( + color: colorScheme.surface, + elevation: 0, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colorScheme.outlineVariant), + ), + ), + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, + thickness: 1, + ), + drawerTheme: DrawerThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + ), + dialogTheme: DialogThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: isDark ? colorScheme.surfaceContainerLow : colorScheme.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.primary, width: 1.4), + ), + labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + prefixIconColor: colorScheme.onSurfaceVariant, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface, + side: BorderSide(color: colorScheme.outline), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + tabBarTheme: TabBarThemeData( + dividerColor: colorScheme.outlineVariant, + labelColor: colorScheme.onSurface, + unselectedLabelColor: colorScheme.onSurfaceVariant, + indicatorColor: colorScheme.primary, + ), + ); +} + +class NoTransitionsBuilder extends PageTransitionsBuilder { + const NoTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return child; + } +} diff --git a/userfront/lib/core/theme/theme_controller.dart b/userfront/lib/core/theme/theme_controller.dart new file mode 100644 index 00000000..5e00b4b9 --- /dev/null +++ b/userfront/lib/core/theme/theme_controller.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemeController extends ValueNotifier { + ThemeController._(this.storageKey) : super(ThemeMode.light); + + static const appStorageKey = 'userfront_theme'; + static const authStorageKey = 'userfront_auth_theme'; + static final ThemeController app = ThemeController._(appStorageKey); + static final ThemeController auth = ThemeController._(authStorageKey); + static final ThemeController instance = app; + + final String storageKey; + + bool get isDark => value == ThemeMode.dark; + + Future restore() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(storageKey); + value = stored == 'dark' ? ThemeMode.dark : ThemeMode.light; + } + + Future setThemeMode(ThemeMode mode) async { + if (value != mode) { + value = mode; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + storageKey, + mode == ThemeMode.dark ? 'dark' : 'light', + ); + } + + Future toggle() { + return setThemeMode(isDark ? ThemeMode.light : ThemeMode.dark); + } +} diff --git a/userfront/lib/core/theme/theme_scope.dart b/userfront/lib/core/theme/theme_scope.dart new file mode 100644 index 00000000..2f912d5f --- /dev/null +++ b/userfront/lib/core/theme/theme_scope.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'app_theme.dart'; +import 'theme_controller.dart'; + +class ThemeScope extends InheritedWidget { + const ThemeScope({super.key, required this.controller, required Widget child}) + : super(child: child); + + final ThemeController controller; + + static ThemeController of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + return scope?.controller ?? ThemeController.app; + } + + @override + bool updateShouldNotify(ThemeScope oldWidget) { + return oldWidget.controller != controller; + } +} + +class ScopedTheme extends StatelessWidget { + const ScopedTheme({super.key, required this.controller, required this.child}); + + final ThemeController controller; + final Widget child; + + @override + Widget build(BuildContext context) { + return ThemeScope( + controller: controller, + child: ValueListenableBuilder( + valueListenable: controller, + builder: (context, mode, _) { + return Theme( + data: mode == ThemeMode.dark ? buildDarkTheme() : buildLightTheme(), + child: child, + ); + }, + ), + ); + } +} diff --git a/userfront/lib/core/widgets/theme_toggle_button.dart b/userfront/lib/core/widgets/theme_toggle_button.dart new file mode 100644 index 00000000..05737ad8 --- /dev/null +++ b/userfront/lib/core/widgets/theme_toggle_button.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:userfront/i18n.dart'; + +import '../theme/theme_scope.dart'; + +class ThemeToggleButton extends StatelessWidget { + const ThemeToggleButton({super.key, this.compact = false}); + + final bool compact; + + @override + Widget build(BuildContext context) { + Localizations.localeOf(context); + final controller = ThemeScope.of(context); + + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, mode, _) { + final isLight = mode == ThemeMode.light; + final icon = isLight + ? Icons.light_mode_outlined + : Icons.dark_mode_outlined; + final label = isLight + ? tr('ui.common.theme_light', fallback: 'Light') + : tr('ui.common.theme_dark', fallback: 'Dark'); + final tooltip = tr('ui.common.theme_toggle', fallback: '테마 전환'); + + if (compact) { + return IconButton( + tooltip: tooltip, + onPressed: () => controller.toggle(), + icon: Icon(icon), + ); + } + + return OutlinedButton.icon( + onPressed: () => controller.toggle(), + icon: Icon(icon, size: 18), + label: Text(label), + ); + }, + ); + } +} diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 0d3acd05..46a0ed82 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -57,6 +57,40 @@ class _ConsentScreenState extends State { }; } + String _renderConsentText(String key, {String? fallback}) { + return tr( + key, + fallback: fallback, + ).replaceAll(r'\\n', '\n').replaceAll(r'\n', '\n').replaceAll('\\\n', '\n'); + } + + String _renderScopeCountLabel(int count) { + return tr( + 'msg.userfront.consent.scope_count', + fallback: 'Total {{count}}', + params: {'count': '$count'}, + ).replaceAll('{$count}', '$count'); + } + + String _scopeDisplayLabel(String scope) { + if (scope == 'offline_access') { + return 'offline access'; + } + return scope.replaceAll('_', ' '); + } + + String _renderClientIdLabel(String clientId) { + final raw = tr( + 'msg.userfront.consent.client_id', + fallback: 'Client ID: {{id}}', + ); + final normalized = raw + .replaceAll('{{id}}', '') + .replaceAll('{id}', '') + .trimRight(); + return '$normalized $clientId'; + } + Future _fetchConsentInfo() async { try { final info = await AuthProxyService.getConsentInfo( @@ -271,7 +305,7 @@ class _ConsentScreenState extends State { ), const SizedBox(height: 12), Text( - tr('msg.userfront.consent.description'), + _renderConsentText('msg.userfront.consent.description'), style: TextStyle(fontSize: 14, color: Colors.grey[600]), textAlign: TextAlign.center, ), @@ -318,11 +352,7 @@ class _ConsentScreenState extends State { ), const SizedBox(height: 4), Text( - tr( - 'msg.userfront.consent.client_id', - fallback: 'Client ID: {{id}}', - params: {'id': clientId}, - ), + _renderClientIdLabel(clientId), style: TextStyle( fontSize: 12, color: Colors.grey[500], @@ -349,11 +379,7 @@ class _ConsentScreenState extends State { ), ), Text( - tr( - 'msg.userfront.consent.scope_count', - fallback: 'Total {{count}}', - params: {'count': '${requestedScopes.length}'}, - ), + _renderScopeCountLabel(requestedScopes.length), style: TextStyle( fontSize: 14, color: Theme.of(context).primaryColor, @@ -371,7 +397,7 @@ class _ConsentScreenState extends State { return CheckboxListTile( title: Text( - scope, // 스코프 키 (예: openid) + _scopeDisplayLabel(scope), style: const TextStyle(fontWeight: FontWeight.w500), ), subtitle: Text(description), diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index b2ebc876..7977c0b6 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/constants/error_whitelist.dart'; import '../../../core/i18n/locale_utils.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/widgets/theme_toggle_button.dart'; import 'package:userfront/i18n.dart'; class ErrorScreen extends StatelessWidget { @@ -22,6 +23,7 @@ class ErrorScreen extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final colorScheme = theme.colorScheme; final isProd = isProdOverride ?? AuthProxyService.isProdEnv; final normalizedCode = (errorCode ?? '').trim(); final hasCode = normalizedCode.isNotEmpty; @@ -62,7 +64,7 @@ class ErrorScreen extends StatelessWidget { : tr('msg.userfront.error.detail_request'))); return Scaffold( - backgroundColor: const Color(0xFFF7F8FA), + backgroundColor: colorScheme.surfaceContainerLowest, body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), @@ -71,7 +73,7 @@ class ErrorScreen extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: Color(0xFFE5E7EB)), + side: BorderSide(color: colorScheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), @@ -79,18 +81,25 @@ class ErrorScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - color: const Color(0xFF111827), - ), + Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + ), + const ThemeToggleButton(compact: true), + ], ), const SizedBox(height: 12), Text( detail, style: theme.textTheme.bodyMedium?.copyWith( - color: const Color(0xFF4B5563), + color: colorScheme.onSurfaceVariant, height: 1.5, ), ), @@ -98,7 +107,7 @@ class ErrorScreen extends StatelessWidget { Text( tr('msg.userfront.error.type', params: {'type': errorType}), style: theme.textTheme.bodySmall?.copyWith( - color: const Color(0xFF6B7280), + color: colorScheme.onSurfaceVariant, ), ), if (errorId != null && errorId!.isNotEmpty) ...[ @@ -106,7 +115,7 @@ class ErrorScreen extends StatelessWidget { Text( tr('msg.userfront.error.id', params: {'id': errorId!}), style: theme.textTheme.bodySmall?.copyWith( - color: const Color(0xFF6B7280), + color: colorScheme.onSurfaceVariant, ), ), ], @@ -118,8 +127,8 @@ class ErrorScreen extends StatelessWidget { ElevatedButton( onPressed: () => context.go('/login'), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF111827), - foregroundColor: Colors.white, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, @@ -134,12 +143,12 @@ class ErrorScreen extends StatelessWidget { onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF111827), + foregroundColor: colorScheme.onSurface, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), - side: const BorderSide(color: Color(0xFFCBD5F5)), + side: BorderSide(color: colorScheme.outline), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index ec300fe3..bcb02973 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:userfront/i18n.dart'; import '../../../core/widgets/language_selector.dart'; +import '../../../core/widgets/theme_toggle_button.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; @@ -1032,13 +1033,22 @@ class _LoginScreenState extends ConsumerState webWindow.redirectTo(redirectTo); } else {} } catch (e) { + final errorMessage = e.toString().replaceFirst('Exception: ', ''); + try { + await AuthProxyService.logError( + '[PasswordLogin] $errorMessage', + error: e, + ); + } catch (_) { + // Ignore client-log relay failures and continue with user feedback. + } if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { _showError( tr( 'msg.userfront.login.password.failed', - params: {'error': e.toString().replaceFirst('Exception: ', '')}, + params: {'error': errorMessage}, ), ); } @@ -1376,6 +1386,77 @@ class _LoginScreenState extends ConsumerState @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final mutedColor = colorScheme.onSurfaceVariant; + final inputForegroundColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFFE2E8F0) + : const Color(0xFF334155); + final primaryColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFF93C5FD) + : const Color(0xFF1E3A8A); + final onPrimaryColor = colorScheme.brightness == Brightness.dark + ? const Color(0xFF0F172A) + : Colors.white; + final inputDecorationTheme = theme.inputDecorationTheme.copyWith( + filled: false, + fillColor: Colors.transparent, + contentPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: primaryColor, width: 1.6), + ), + labelStyle: TextStyle(color: inputForegroundColor), + floatingLabelStyle: TextStyle(color: primaryColor), + hintStyle: TextStyle(color: inputForegroundColor), + prefixIconColor: inputForegroundColor, + ); + final localTheme = theme.copyWith( + inputDecorationTheme: inputDecorationTheme, + tabBarTheme: theme.tabBarTheme.copyWith( + dividerColor: colorScheme.outlineVariant, + indicatorColor: primaryColor, + labelColor: colorScheme.onSurface, + unselectedLabelColor: mutedColor, + labelStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + unselectedLabelStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + backgroundColor: primaryColor, + foregroundColor: onPrimaryColor, + textStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryColor, + textStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ); + if (_verificationOnly && _verificationApproved) { return Scaffold( appBar: AppBar( @@ -1384,558 +1465,591 @@ class _LoginScreenState extends ConsumerState icon: const Icon(Icons.arrow_back), onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), ), + actions: const [ThemeToggleButton(compact: true)], ), body: _buildVerificationResultView(), ); } return Scaffold( + backgroundColor: colorScheme.surfaceContainerLowest, body: LayoutBuilder( builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 400), - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('ui.userfront.app_title'), - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, + return Theme( + data: localTheme, + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 480), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 40, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('ui.userfront.app_title'), + style: theme.textTheme.headlineMedium?.copyWith( + fontSize: 34, + fontWeight: FontWeight.w800, + letterSpacing: -0.7, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - if (_drySendEnabled) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - decoration: BoxDecoration( - color: const Color(0xFFFFF3CD), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFFFC107)), - ), - child: Row( - children: [ - const Icon( - Icons.warning_amber_rounded, - color: Color(0xFF8A6D3B), + if (_drySendEnabled) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFFFC107), ), - const SizedBox(width: 8), - Expanded( - child: Text( - tr('msg.userfront.login.dry_send'), - style: const TextStyle( - color: Color(0xFF8A6D3B), - fontSize: 12, + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Color(0xFF8A6D3B), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + tr('msg.userfront.login.dry_send'), + style: const TextStyle( + color: Color(0xFF8A6D3B), + fontSize: 12, + ), ), ), + ], + ), + ), + ], + const SizedBox(height: 52), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 34), + child: TabBar( + controller: _tabController, + indicatorSize: TabBarIndicatorSize.label, + tabs: [ + Tab(text: tr('ui.userfront.login.tabs.password')), + Tab(text: tr('ui.userfront.login.tabs.link')), + Tab(text: tr('ui.userfront.login.tabs.qr')), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + height: 360, + child: TabBarView( + controller: _tabController, + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 356, + ), + child: Column( + children: [ + TextField( + key: const ValueKey( + 'password_login_id_input', + ), + controller: + _passwordLoginIdController, + decoration: InputDecoration( + labelText: + _loginIdLabel ?? + tr( + 'ui.userfront.login.field.login_id', + ), + prefixIcon: const Icon( + Icons.person_outline, + size: 22, + ), + ), + onSubmitted: (_) => + _handlePasswordLogin(), + ), + const SizedBox(height: 18), + TextField( + key: const ValueKey( + 'password_login_password_input', + ), + focusNode: _passwordFocusNode, + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.password', + ), + prefixIcon: const Icon( + Icons.lock_outline, + size: 22, + ), + ), + onSubmitted: (_) => + _handlePasswordLogin(), + ), + if (_isPasswordCapsLockOn) ...[ + const SizedBox(height: 10), + 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: 28), + FilledButton( + key: const ValueKey( + 'password_login_submit_button', + ), + onPressed: _handlePasswordLogin, + child: Text( + tr( + 'ui.userfront.login.action.submit', + ), + ), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 356, + ), + child: Column( + children: [ + if (_linkPendingRef == null) ...[ + TextField( + controller: _linkIdController, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.field.login_id', + ), + hintText: '', + prefixIcon: const Icon( + Icons.person_outline, + size: 22, + ), + ), + onSubmitted: (_) => + _handleLinkLogin(), + ), + const SizedBox(height: 28), + FilledButton( + onPressed: _handleLinkLogin, + child: Text( + tr( + 'ui.userfront.login.link.send', + ), + ), + ), + const SizedBox(height: 24), + Text( + tr( + 'msg.userfront.login.link.helper', + ), + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], + if (_linkPendingRef != null) ...[ + if (_linkExpired) ...[ + Text( + tr( + 'msg.userfront.login.link_timeout', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () { + setState(_resetLinkLoginState); + }, + child: Text( + tr('ui.common.refresh'), + ), + ), + ] else ...[ + Text( + tr( + 'msg.userfront.login.link.short_code_help', + ), + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: + _shortCodePrefixController, + textCapitalization: + TextCapitalization + .characters, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.prefix', + ), + hintText: 'AB', + hintStyle: TextStyle( + color: mutedColor, + ), + counterText: '', + ), + maxLength: 2, + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 4, + child: TextField( + controller: + _shortCodeDigitsController, + keyboardType: + TextInputType.number, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.login.short_code.digits', + ), + hintText: '345678', + hintStyle: TextStyle( + color: mutedColor, + ), + counterText: '', + suffixText: + _linkExpireSeconds > 0 + ? tr( + 'ui.userfront.login.short_code.expire_time', + params: { + 'time': _formatTime( + _linkExpireSeconds, + ), + }, + ) + : null, + ), + maxLength: 6, + ), + ), + ], + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () { + final prefix = + _shortCodePrefixController + .text + .trim() + .toUpperCase(); + final digits = + _shortCodeDigitsController + .text + .trim(); + if (prefix.length != 2 || + digits.length != 6) { + _showError( + tr( + 'msg.userfront.login.short_code.invalid', + ), + ); + return; + } + _verifyShortCode( + prefix + digits, + ); + }, + child: Text( + tr( + 'ui.userfront.login.short_code.submit', + ), + ), + ), + const SizedBox(height: 14), + TextButton( + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo( + tr( + 'msg.userfront.login.link.resend_wait', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ); + return; + } + final loginId = + _lastLinkLoginId ?? + _linkIdController.text + .trim(); + if (loginId.isEmpty) { + _showError( + tr( + 'msg.userfront.login.link.missing_login_id', + ), + ); + return; + } + _startEnchantedFlow( + loginId, + isEmail: + _lastLinkIsEmail || + loginId.contains('@'), + codeOnly: false, + ); + }, + child: Text( + _linkResendSeconds > 0 + ? tr( + 'ui.userfront.login.link.resend_with_time', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ) + : tr('ui.common.resend'), + ), + ), + if (!_lastLinkIsEmail) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo( + tr( + 'msg.userfront.login.link.resend_wait', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ); + return; + } + final loginId = + _lastLinkLoginId ?? + _linkIdController.text + .trim(); + if (loginId.isEmpty) { + _showError( + tr( + 'msg.userfront.login.link.missing_phone', + ), + ); + return; + } + _startEnchantedFlow( + loginId, + isEmail: false, + codeOnly: true, + ); + }, + child: Text( + tr( + 'ui.userfront.login.link.code_only', + params: { + 'time': _formatTime( + _linkResendSeconds, + ), + }, + ), + ), + ), + ], + ], + ], + ], + ), + ), + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (_isQrLoading) + const CircularProgressIndicator() + else if (_qrExpired) + Column( + children: [ + Text( + tr('msg.userfront.login.qr_expired'), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: _startQrFlow, + child: Text(tr('ui.common.refresh')), + ), + ], + ) + else if (_qrImageBase64 != null) + Column( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + border: Border.all( + color: colorScheme.outline, + ), + borderRadius: BorderRadius.circular( + 18, + ), + ), + child: QrImageView( + data: _qrImageBase64!, + version: QrVersions.auto, + size: 200.0, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 14), + Text( + _qrRemainingSeconds > 0 + ? tr( + 'ui.userfront.login.qr.remaining', + params: { + 'time': _formatTime( + _qrRemainingSeconds, + ), + }, + ) + : tr( + 'ui.userfront.login.qr.expired', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: _qrRemainingSeconds > 30 + ? primaryColor + : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + tr( + 'msg.userfront.login.qr.scan_hint', + ), + textAlign: TextAlign.center, + style: TextStyle( + color: mutedColor, + fontSize: 12, + height: 1.5, + ), + ), + TextButton( + onPressed: _startQrFlow, + child: Text( + tr('ui.userfront.login.qr.refresh'), + ), + ), + ], + ) + else + Text( + tr('msg.userfront.login.qr.load_failed'), + textAlign: TextAlign.center, + ), + ], ), ], ), ), - ], - const SizedBox(height: 40), - - TabBar( - controller: _tabController, - tabs: [ - Tab(text: tr('ui.userfront.login.tabs.password')), - Tab(text: tr('ui.userfront.login.tabs.link')), - Tab(text: tr('ui.userfront.login.tabs.qr')), - ], - ), - const SizedBox(height: 24), - - SizedBox( - height: 350, - child: TabBarView( - controller: _tabController, + const SizedBox(height: 18), + Column( children: [ - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( - children: [ - TextField( - key: const ValueKey( - 'password_login_id_input', - ), - controller: _passwordLoginIdController, - decoration: InputDecoration( - labelText: - _loginIdLabel ?? - tr( - 'ui.userfront.login.field.login_id', - ), - border: const OutlineInputBorder(), - prefixIcon: const Icon( - Icons.person_outline, - ), - ), - onSubmitted: (_) => _handlePasswordLogin(), - ), - const SizedBox(height: 16), - TextField( - key: const ValueKey( - 'password_login_password_input', - ), - focusNode: _passwordFocusNode, - controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.field.password', - ), - border: const OutlineInputBorder(), - prefixIcon: const Icon( - Icons.lock_outline, - ), - ), - 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( - 'password_login_submit_button', - ), - onPressed: _handlePasswordLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), - child: Text( - tr('ui.userfront.login.action.submit'), - ), - ), - ], + TextButton( + onPressed: () => context.push('/forgot-password'), + child: Text( + tr('ui.userfront.login.forgot_password'), ), ), - - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: Column( - children: [ - if (_linkPendingRef == null) ...[ - TextField( - controller: _linkIdController, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.field.login_id', - ), - hintText: '', - border: const OutlineInputBorder(), - prefixIcon: const Icon( - Icons.person_outline, - ), - ), - onSubmitted: (_) => _handleLinkLogin(), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: _handleLinkLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), - ), - child: Text( - tr('ui.userfront.login.link.send'), - ), - ), - const SizedBox(height: 24), - Text( - tr('msg.userfront.login.link.helper'), - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], - if (_linkPendingRef != null) ...[ - if (_linkExpired) ...[ - Text( - tr('msg.userfront.login.link_timeout'), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () { - setState(_resetLinkLoginState); - }, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 45, - ), - ), - child: Text(tr('ui.common.refresh')), - ), - ] else ...[ - Text( - tr( - 'msg.userfront.login.link.short_code_help', - ), - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - flex: 2, - child: TextField( - controller: - _shortCodePrefixController, - textCapitalization: - TextCapitalization.characters, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.short_code.prefix', - ), - border: - const OutlineInputBorder(), - hintText: 'AB', - hintStyle: const TextStyle( - color: Colors.grey, - ), - ), - maxLength: 2, - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 4, - child: TextField( - controller: - _shortCodeDigitsController, - keyboardType: - TextInputType.number, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.login.short_code.digits', - ), - border: - const OutlineInputBorder(), - hintText: '345678', - hintStyle: const TextStyle( - color: Colors.grey, - ), - suffixText: - _linkExpireSeconds > 0 - ? tr( - 'ui.userfront.login.short_code.expire_time', - params: { - 'time': _formatTime( - _linkExpireSeconds, - ), - }, - ) - : null, - ), - maxLength: 6, - ), - ), - ], - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () { - final prefix = - _shortCodePrefixController.text - .trim() - .toUpperCase(); - final digits = - _shortCodeDigitsController.text - .trim(); - if (prefix.length != 2 || - digits.length != 6) { - _showError( - tr( - 'msg.userfront.login.short_code.invalid', - ), - ); - return; - } - _verifyShortCode(prefix + digits); - }, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 45, - ), - ), - child: Text( - tr( - 'ui.userfront.login.short_code.submit', - ), - ), - ), - const SizedBox(height: 12), - TextButton( - onPressed: () { - if (_linkResendSeconds > 0) { - _showInfo( - tr( - 'msg.userfront.login.link.resend_wait', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), - ); - return; - } - final loginId = - _lastLinkLoginId ?? - _linkIdController.text.trim(); - if (loginId.isEmpty) { - _showError( - tr( - 'msg.userfront.login.link.missing_login_id', - ), - ); - return; - } - _startEnchantedFlow( - loginId, - isEmail: - _lastLinkIsEmail || - loginId.contains('@'), - codeOnly: false, - ); - }, - child: Text( - _linkResendSeconds > 0 - ? tr( - 'ui.userfront.login.link.resend_with_time', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ) - : tr('ui.common.resend'), - ), - ), - if (!_lastLinkIsEmail) ...[ - const SizedBox(height: 4), - TextButton( - onPressed: () { - if (_linkResendSeconds > 0) { - _showInfo( - tr( - 'msg.userfront.login.link.resend_wait', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), - ); - return; - } - final loginId = - _lastLinkLoginId ?? - _linkIdController.text.trim(); - if (loginId.isEmpty) { - _showError( - tr( - 'msg.userfront.login.link.missing_phone', - ), - ); - return; - } - _startEnchantedFlow( - loginId, - isEmail: false, - codeOnly: true, - ); - }, - child: Text( - tr( - 'ui.userfront.login.link.code_only', - params: { - 'time': _formatTime( - _linkResendSeconds, - ), - }, - ), - ), - ), - ], - ], - ], - ], - ), - ), - - Column( + Row( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (_isQrLoading) - const CircularProgressIndicator() - else if (_qrExpired) - Column( - children: [ - Text( - tr('msg.userfront.login.qr_expired'), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: _startQrFlow, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight( - 45, - ), - ), - child: Text(tr('ui.common.refresh')), - ), - ], - ) - else if (_qrImageBase64 != null) - Column( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.shade300, - ), - borderRadius: BorderRadius.circular( - 12, - ), - ), - child: QrImageView( - data: _qrImageBase64!, - version: QrVersions.auto, - size: 200.0, - ), - ), - const SizedBox(height: 12), - Text( - _qrRemainingSeconds > 0 - ? tr( - 'ui.userfront.login.qr.remaining', - params: { - 'time': _formatTime( - _qrRemainingSeconds, - ), - }, - ) - : tr( - 'ui.userfront.login.qr.expired', - ), - textAlign: TextAlign.center, - style: TextStyle( - color: _qrRemainingSeconds > 30 - ? Colors.blue - : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - tr('msg.userfront.login.qr.scan_hint'), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - ), - ), - TextButton( - onPressed: _startQrFlow, - child: Text( - tr('ui.userfront.login.qr.refresh'), - ), - ), - ], - ) - else - Text( - tr('msg.userfront.login.qr.load_failed'), - textAlign: TextAlign.center, + Text( + tr('msg.userfront.login.no_account'), + style: TextStyle( + color: mutedColor, + fontSize: 14, ), + ), + TextButton( + onPressed: () => context.push('/signup'), + child: Text(tr('ui.userfront.login.signup')), + ), ], ), ], ), - ), - const SizedBox(height: 16), - Column( - children: [ - TextButton( - onPressed: () => context.push('/forgot-password'), - child: Text( - tr('ui.userfront.login.forgot_password'), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - tr('msg.userfront.login.no_account'), - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), - TextButton( - onPressed: () => context.push('/signup'), - child: Text(tr('ui.userfront.login.signup')), - ), - ], - ), - ], - ), - const SizedBox(height: 6), - const Align( - alignment: Alignment.center, - child: LanguageSelector(), - ), - ], + const SizedBox(height: 12), + const Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ThemeToggleButton(), LanguageSelector()], + ), + ], + ), ), ), ), diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index f2951e1c..53cbdd65 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -26,6 +26,18 @@ class _ResetPasswordScreenState extends State { Map? _policy; bool _isPolicyLoading = false; + String _renderTranslatedText( + String key, { + String? fallback, + Map values = const {}, + }) { + var text = tr(key, fallback: fallback); + values.forEach((name, value) { + text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value); + }); + return text; + } + @override void initState() { super.initState(); @@ -123,16 +135,16 @@ class _ResetPasswordScreenState extends State { final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; final parts = [ - tr( + _renderTranslatedText( 'msg.userfront.reset.policy.min_length', - params: {'count': '$minLength'}, + values: {'count': '$minLength'}, ), ]; if (minTypes > 0) { parts.add( - tr( + _renderTranslatedText( 'msg.userfront.reset.policy.min_types', - params: {'count': '$minTypes'}, + values: {'count': '$minTypes'}, ), ); } diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 20faea8b..8e6e5058 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -69,6 +69,18 @@ class _SignupScreenState extends State { Timer? _phoneTimer; int _phoneSeconds = 0; + String _renderTranslatedText( + String key, { + String? fallback, + Map values = const {}, + }) { + var text = tr(key, fallback: fallback); + values.forEach((name, value) { + text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value); + }); + return text; + } + @override void initState() { super.initState(); @@ -1663,16 +1675,16 @@ class _SignupScreenState extends State { final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; final parts = [ - tr( + _renderTranslatedText( 'msg.userfront.signup.policy.min_length', - params: {'count': minLength.toString()}, + values: {'count': minLength.toString()}, ), ]; if (minTypes > 0) { parts.add( - tr( + _renderTranslatedText( 'msg.userfront.signup.policy.min_types', - params: {'count': minTypes.toString()}, + values: {'count': minTypes.toString()}, ), ); } @@ -1689,9 +1701,9 @@ class _SignupScreenState extends State { parts.add(tr('msg.userfront.signup.policy.symbol')); } - return tr( + return _renderTranslatedText( 'msg.userfront.signup.policy.summary', - params: {'rules': parts.join(', ')}, + values: {'rules': parts.join(', ')}, ); } diff --git a/userfront/lib/features/dashboard/domain/linked_rp_launch.dart b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart new file mode 100644 index 00000000..cb7cb716 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart @@ -0,0 +1,21 @@ +import 'providers/linked_rps_provider.dart'; + +String? resolveLinkedRpLaunchUrl(LinkedRp rp) { + final normalizedStatus = rp.status.trim().toLowerCase(); + final isActive = normalizedStatus.isEmpty || normalizedStatus == 'active'; + if (!isActive) { + return null; + } + + final initUrl = rp.initUrl.trim(); + if (initUrl.isNotEmpty) { + return initUrl; + } + + final url = rp.url.trim(); + if (url.isNotEmpty) { + return url; + } + + return null; +} diff --git a/userfront/lib/features/dashboard/domain/models.dart b/userfront/lib/features/dashboard/domain/models.dart index f45c858f..0fa73d4e 100644 --- a/userfront/lib/features/dashboard/domain/models.dart +++ b/userfront/lib/features/dashboard/domain/models.dart @@ -96,6 +96,7 @@ class LinkedRp { final String name; final String logo; final String url; + final String initUrl; final String status; final List scopes; final DateTime? lastAuthenticatedAt; @@ -105,6 +106,7 @@ class LinkedRp { required this.name, required this.logo, required this.url, + required this.initUrl, required this.status, required this.scopes, this.lastAuthenticatedAt, @@ -126,6 +128,7 @@ class LinkedRp { name: json['name']?.toString() ?? '', logo: json['logo']?.toString() ?? '', url: json['url']?.toString() ?? '', + initUrl: json['init_url']?.toString() ?? '', status: json['status']?.toString() ?? '', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastAuthenticatedAt: parsedLastAuth, @@ -170,3 +173,59 @@ class RpHistoryItem { ); } } + +class UserSessionSummary { + final String sessionId; + final DateTime? authenticatedAt; + final DateTime? expiresAt; + final DateTime? issuedAt; + final DateTime? lastSeenAt; + final String ipAddress; + final String userAgent; + final String clientId; + final String appName; + final bool isCurrent; + final bool isActive; + + UserSessionSummary({ + required this.sessionId, + this.authenticatedAt, + this.expiresAt, + this.issuedAt, + this.lastSeenAt, + required this.ipAddress, + required this.userAgent, + required this.clientId, + required this.appName, + required this.isCurrent, + required this.isActive, + }); + + factory UserSessionSummary.fromJson(Map json) { + DateTime? parseDate(dynamic raw) { + final value = raw?.toString(); + if (value == null || value.isEmpty) { + return null; + } + try { + return DateTime.parse(value).toLocal(); + } catch (_) { + return null; + } + } + + return UserSessionSummary( + sessionId: json['session_id']?.toString() ?? '', + authenticatedAt: parseDate(json['authenticated_at']), + expiresAt: parseDate(json['expires_at']), + issuedAt: parseDate(json['issued_at']), + lastSeenAt: parseDate(json['last_seen_at']), + ipAddress: json['ip_address']?.toString() ?? '', + userAgent: json['user_agent']?.toString() ?? '', + clientId: json['client_id']?.toString() ?? '', + appName: json['app_name']?.toString() ?? '', + isCurrent: json['is_current'] == true, + isActive: json['is_active'] != false, + ); + } +} diff --git a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart index b571351c..2c8ddbd3 100644 --- a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart +++ b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart @@ -10,6 +10,7 @@ class LinkedRp { final String name; final String logo; final String url; + final String initUrl; final String status; final List scopes; final DateTime? lastAuthenticatedAt; @@ -19,6 +20,7 @@ class LinkedRp { required this.name, required this.logo, required this.url, + required this.initUrl, required this.status, required this.scopes, required this.lastAuthenticatedAt, @@ -40,6 +42,7 @@ class LinkedRp { name: json['name']?.toString() ?? '', logo: json['logo']?.toString() ?? '', url: json['url']?.toString() ?? '', + initUrl: json['init_url']?.toString() ?? '', status: json['status']?.toString() ?? 'unknown', scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], lastAuthenticatedAt: parsedLastAuth, diff --git a/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart b/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart new file mode 100644 index 00000000..881a88f0 --- /dev/null +++ b/userfront/lib/features/dashboard/domain/providers/user_sessions_provider.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/http_client.dart'; +import '../models.dart'; + +class UserSessionsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return _fetchSessions(); + } + + String _envOrDefault(String key, String fallback) { + if (!dotenv.isInitialized) { + return fallback; + } + return dotenv.env[key] ?? fallback; + } + + Future> _fetchSessions() async { + final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + final url = Uri.parse('$baseUrl/api/v1/user/sessions'); + + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + + final client = createHttpClient(withCredentials: useCookie); + final headers = {'Content-Type': 'application/json'}; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + try { + final response = await client.get(url, headers: headers); + if (response.statusCode != 200) { + throw Exception('Failed to load sessions: ${response.statusCode}'); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? const []; + return items + .whereType>() + .map(UserSessionSummary.fromJson) + .toList(); + } finally { + client.close(); + } + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(_fetchSessions); + } + + Future revokeSession(String sessionId) async { + await AuthProxyService.revokeSession(sessionId); + await refresh(); + } +} + +final userSessionsProvider = + AsyncNotifierProvider>(() { + return UserSessionsNotifier(); + }); diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 11f80c7a..09649860 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -2,19 +2,24 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; 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/linked_rp_launch.dart'; import '../domain/session_time_resolver.dart'; import '../domain/providers/linked_rps_provider.dart'; +import '../domain/providers/user_sessions_provider.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/logout_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/widgets/language_selector.dart'; +import '../../../../core/widgets/theme_toggle_button.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; @@ -30,13 +35,21 @@ class DashboardScreen extends ConsumerStatefulWidget { } class _DashboardScreenState extends ConsumerState { - static const _ink = Color(0xFF1A1F2C); - static const _surface = Colors.white; - static const _border = Color(0xFFE5E7EB); - static const _subtle = Color(0xFFF7F8FA); + static const double _dashboardCardSpacing = 12; + static const double _dashboardCardMaxWidth = 228; + static const double _activityDialogMaxWidth = 360; static const double _historySessionMinWidth = 92; static const double _historyOtherColumnsBaselineWidth = 780; static const int _historySessionMinVisibleChars = 8; + static const double _historyDateColumnWidth = 132; + static const double _historyAppColumnWidth = 132; + static const double _historyIpColumnWidth = 118; + static const double _historyDeviceColumnWidth = 128; + static const double _historyBrowserColumnWidth = 112; + static const double _historyAuthMethodColumnWidth = 108; + static const double _historyResultColumnWidth = 88; + static const double _historyStatusColumnWidth = 92; + static const double _historyActionColumnWidth = 108; final ScrollController _pageScrollController = ScrollController(); final ScrollController _rpScrollController = ScrollController(); @@ -45,12 +58,32 @@ class _DashboardScreenState extends ConsumerState { bool _auditLoading = false; bool _auditLoadingMore = false; bool _isRevoking = false; + String? _revokingSessionId; bool _redirectingToSignin = false; bool _authBootstrapInProgress = false; bool _showAllActivities = false; + bool _showActiveSessionsOnly = false; + bool _isDesktopSideMenuOpen = true; final Set _revokedClientIds = {}; + Color get _ink => Theme.of(context).colorScheme.onSurface; + Color get _surface => Theme.of(context).colorScheme.surface; + Color get _border => Theme.of(context).colorScheme.outlineVariant; + Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest; + + String _renderTranslatedText( + String key, { + String? fallback, + Map values = const {}, + }) { + var text = tr(key, fallback: fallback); + values.forEach((name, value) { + text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value); + }); + return text; + } + @override void initState() { super.initState(); @@ -71,8 +104,7 @@ class _DashboardScreenState extends ConsumerState { } Future _logout() async { - AuthTokenStore.clear(); - AuthNotifier.instance.notify(); + await LogoutService().logout(); } Future _onRevokeLink(String clientId, String appName) async { @@ -130,6 +162,67 @@ class _DashboardScreenState extends ConsumerState { } } + Future _onRevokeSession(UserSessionSummary session) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')), + content: Text( + _renderTranslatedText( + 'msg.userfront.dashboard.sessions.revoke.confirm', + values: { + 'target': session.isCurrent + ? tr('ui.userfront.dashboard.sessions.current_badge') + : _sessionDisplayLabel(session), + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(tr('ui.common.cancel')), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(tr('ui.userfront.dashboard.sessions.revoke.action')), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + setState(() => _revokingSessionId = session.sessionId); + try { + await ref + .read(userSessionsProvider.notifier) + .revokeSession(session.sessionId); + if (!mounted) { + return; + } + ToastService.success( + tr('msg.userfront.dashboard.sessions.revoke.success'), + ); + } catch (e) { + if (!mounted) { + return; + } + ToastService.error( + tr( + 'msg.userfront.dashboard.sessions.revoke.error', + params: {'error': '$e'}, + ), + ); + } finally { + if (mounted) { + setState(() => _revokingSessionId = null); + } + } + } + void _onScanQR() { context.push('/scan'); } @@ -148,85 +241,158 @@ class _DashboardScreenState extends ConsumerState { context: context, builder: (context) => Consumer( builder: (context, ref, _) { + final dialogWidth = math.min( + MediaQuery.sizeOf(context).width - 48, + _activityDialogMaxWidth, + ); + final statusLabel = item.status == 'active' + ? tr('ui.userfront.dashboard.activity.linked') + : tr('ui.userfront.dashboard.status.revoked'); + final statusColor = _activityStatusColor(item.status); + return AlertDialog( - title: Text(item.appName), + backgroundColor: _surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + insetPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 24, + ), + contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 8), content: SizedBox( - width: double.maxFinite, + width: dialogWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - tr('ui.userfront.dashboard.scopes.title'), - style: const TextStyle(fontWeight: FontWeight.bold), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.appName, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: _ink, + ), + ), + const SizedBox(height: 4), + Text( + tr('ui.common.details'), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + ], + ), ), - const SizedBox(height: 8), - if (item.scopes.isEmpty) - Text( - tr('msg.userfront.dashboard.scopes.empty'), - style: const TextStyle(color: Colors.grey), - ) - else - Wrap( - spacing: 8, - runSpacing: 4, - children: item.scopes - .map( - (s) => Chip( - label: Text( - s, - style: const TextStyle(fontSize: 12), - ), - visualDensity: VisualDensity.compact, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, + const SizedBox(height: 16), + _buildActivityDetailSection( + title: tr('ui.userfront.dashboard.status_history'), + child: Row( + children: [ + Expanded( + child: _buildActivityDetailField( + label: tr( + 'ui.userfront.dashboard.link_status_label', + ), + value: statusLabel, + valueColor: statusColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildActivityDetailField( + label: tr('ui.userfront.dashboard.last_auth_label'), + value: item.lastAuthAt, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + _buildActivityDetailSection( + title: tr('ui.userfront.dashboard.scopes.title'), + child: item.scopes.isEmpty + ? Text( + tr('msg.userfront.dashboard.scopes.empty'), + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], ), ) - .toList(), - ), - const SizedBox(height: 24), - Text( - tr('ui.userfront.dashboard.status_history'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tr( - 'msg.userfront.dashboard.last_auth', - params: {'value': item.lastAuthAt}, - ), - ), - const SizedBox(height: 4), - Builder( - builder: (context) { - final statusLabel = item.status == 'active' - ? tr('ui.common.status.active') - : tr('ui.userfront.dashboard.status.revoked'); - return Text( - tr( - 'msg.userfront.dashboard.current_status', - params: {'status': statusLabel}, - ), - style: TextStyle( - color: item.status == 'active' - ? Colors.green - : Colors.grey, - ), - ); - }, - ), - ], + : Wrap( + spacing: 8, + runSpacing: 8, + children: item.scopes + .map( + (scope) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.shield_outlined, + size: 14, + color: _ink, + ), + const SizedBox(width: 6), + Text( + scope, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + ], + ), + ), + ) + .toList(), + ), ), ], ), ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(tr('ui.common.close')), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + foregroundColor: _ink, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + backgroundColor: _subtle, + ), + child: Text( + tr('ui.common.close'), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), ), ], ); @@ -235,6 +401,73 @@ class _DashboardScreenState extends ConsumerState { ); } + Widget _buildActivityDetailSection({ + required String title, + required Widget child, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: _ink, + ), + ), + const SizedBox(height: 10), + child, + ], + ), + ); + } + + Widget _buildActivityDetailField({ + required String label, + required String value, + Color? valueColor, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 6), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: valueColor ?? _ink, + ), + ), + ], + ), + ); + } + Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) { return SafeArea( child: Column( @@ -290,7 +523,14 @@ class _DashboardScreenState extends ConsumerState { ), const Padding( padding: EdgeInsets.only(bottom: 16), - child: LanguageSelector(compact: true), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ThemeToggleButton(), + SizedBox(height: 8), + LanguageSelector(compact: true), + ], + ), ), ], ), @@ -310,9 +550,11 @@ class _DashboardScreenState extends ConsumerState { _revokedClientIds.clear(); }); ref.invalidate(linkedRpsProvider); + ref.invalidate(userSessionsProvider); await Future.wait([ ref.read(linkedRpsProvider.future), + ref.read(userSessionsProvider.future), ref.read(authTimelineProvider.notifier).refresh(), ]); @@ -418,6 +660,17 @@ class _DashboardScreenState extends ConsumerState { return SelectableText(text, style: style); } + Widget _singleLineText(String text, {TextStyle? style}) { + return Text( + text, + style: style, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ); + } + String _authMethodLabel() { if (AuthTokenStore.usesCookie()) { return tr('ui.userfront.auth_method.ory'); @@ -634,10 +887,10 @@ class _DashboardScreenState extends ConsumerState { final clientId = log.clientId; final tooltip = clientId.isEmpty ? tr('msg.userfront.dashboard.client_id_missing') - : tr( + : _renderTranslatedText( 'msg.userfront.dashboard.client_id', fallback: 'Client ID: {{id}}', - params: {'id': clientId}, + values: {'id': clientId}, ); final baseStyle = style ?? const TextStyle(); final emphasisStyle = clientId.isEmpty @@ -700,14 +953,35 @@ class _DashboardScreenState extends ConsumerState { return Scaffold( backgroundColor: _subtle, appBar: AppBar( + leading: isWide + ? IconButton( + icon: Icon( + _isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu, + ), + tooltip: _isDesktopSideMenuOpen + ? tr('ui.common.collapse') + : '펼치기', + onPressed: () { + setState(() { + _isDesktopSideMenuOpen = !_isDesktopSideMenuOpen; + }); + }, + ) + : Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + tooltip: MaterialLocalizations.of( + context, + ).openAppDrawerTooltip, + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), title: Text( tr('ui.userfront.app_title'), style: const TextStyle(fontWeight: FontWeight.bold), ), - elevation: 0, - backgroundColor: _surface, - foregroundColor: Colors.black, actions: [ + const ThemeToggleButton(compact: true), IconButton( icon: const Icon(Icons.person_outline), tooltip: tr('ui.userfront.nav.profile'), @@ -730,7 +1004,7 @@ class _DashboardScreenState extends ConsumerState { : Drawer(child: _buildSideMenu(context, closeOnTap: true)), body: Row( children: [ - if (isWide) + if (isWide && _isDesktopSideMenuOpen) SizedBox( width: 240, child: _buildSideMenu(context, closeOnTap: false), @@ -818,8 +1092,12 @@ class _DashboardScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - tr('msg.userfront.greeting', params: {'name': userName}), - style: const TextStyle( + _renderTranslatedText( + 'msg.userfront.greeting', + fallback: 'Hello, {{name}}.', + values: {'name': userName}, + ), + style: TextStyle( fontSize: 22, fontWeight: FontWeight.bold, color: _ink, @@ -871,7 +1149,7 @@ class _DashboardScreenState extends ConsumerState { children: [ Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: _ink, @@ -883,6 +1161,133 @@ class _DashboardScreenState extends ConsumerState { ); } + String _sessionDisplayLabel(UserSessionSummary session) { + if (session.userAgent.trim().isNotEmpty) { + return _sessionUserAgentLabel(session.userAgent); + } + return tr('ui.userfront.dashboard.sessions.unknown_device'); + } + + String _sessionPrimaryLabel(UserSessionSummary session) { + final appLabel = _sessionAppLabel(session); + if (appLabel.isNotEmpty) { + return appLabel; + } + if (session.isCurrent) { + return 'UserFront'; + } + return tr('ui.userfront.dashboard.sessions.unknown_session'); + } + + String _sessionClientLabel(UserSessionSummary session) { + return ''; + } + + String _sessionAppLabel(UserSessionSummary session) { + final appName = session.appName.trim(); + if (appName.isNotEmpty) { + return appName; + } + final clientId = session.clientId.trim().toLowerCase(); + if (clientId.isEmpty) { + return session.isCurrent ? 'UserFront' : ''; + } + if (clientId.contains('adminfront')) { + return 'AdminFront'; + } + if (clientId.contains('devfront')) { + return 'DevFront'; + } + if (clientId.contains('userfront')) { + return 'UserFront'; + } + if (clientId.contains('baron')) { + return tr('ui.userfront.app_label.baron'); + } + return session.clientId.trim(); + } + + String _sessionUserAgentLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty) { + return tr('ui.userfront.dashboard.sessions.unknown_device'); + } + if (_looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('iphone') || lower.contains('ios')) { + return tr('ui.userfront.device.ios'); + } + if (lower.contains('android')) { + return tr('ui.userfront.device.android'); + } + if (lower.contains('windows')) { + return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)'); + } + if (lower.contains('mac os') || lower.contains('macintosh')) { + return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)'); + } + if (lower.contains('linux')) { + return tr('ui.userfront.device.linux'); + } + return userAgent; + } + + String _sessionBrowserLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('edg/')) { + return 'Edge'; + } + if (lower.contains('chrome/') && !lower.contains('edg/')) { + return 'Chrome'; + } + if (lower.contains('firefox/')) { + return 'Firefox'; + } + if (lower.contains('safari/') && !lower.contains('chrome/')) { + return 'Safari'; + } + if (lower.contains('samsungbrowser/')) { + return 'Samsung Internet'; + } + if (lower.contains('flutter')) { + return 'Flutter'; + } + return ''; + } + + String _sessionOsLabel(String userAgent) { + final lower = userAgent.toLowerCase(); + if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { + return ''; + } + if (lower.contains('iphone') || lower.contains('ios')) { + return 'iOS'; + } + if (lower.contains('android')) { + return 'Android'; + } + if (lower.contains('windows')) { + return 'Windows'; + } + if (lower.contains('mac os') || lower.contains('macintosh')) { + return 'macOS'; + } + if (lower.contains('linux')) { + return 'Linux'; + } + return ''; + } + + bool _looksLikeInternalUserAgent(String userAgent) { + return userAgent.startsWith('go-http-client/') || + userAgent.startsWith('fasthttp') || + userAgent.startsWith('fiber'); + } + Widget _buildInfoChip(IconData icon, String label) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -898,7 +1303,7 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(width: 6), Text( label, - style: const TextStyle( + style: TextStyle( fontSize: 12, color: _ink, fontWeight: FontWeight.w600, @@ -980,12 +1385,14 @@ class _DashboardScreenState extends ConsumerState { _ActivityItem( clientId: rp.id, appName: name, + logo: rp.logo.trim(), lastAuthAt: lastAuthLabel, status: statusCode, scopes: rp.scopes, isRevoked: isRevoked, onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name), url: rp.url, + launchUrl: resolveLinkedRpLaunchUrl(rp), lastAuthDateTime: rp.lastAuthenticatedAt, ), ); @@ -1021,15 +1428,7 @@ class _DashboardScreenState extends ConsumerState { builder: (context, constraints) { final maxWidth = constraints.maxWidth; - // 화면 너비에 따른 컬럼 수 및 초기 표시 개수 결정 - int crossAxisCount; - if (maxWidth > 1200) { - crossAxisCount = 4; - } else if (maxWidth > 800) { - crossAxisCount = 3; - } else { - crossAxisCount = 2; - } + final crossAxisCount = _dashboardCardColumnCount(maxWidth); // 초기 표시 개수는 한 줄에 표시되는 개수와 동일하게 설정 (요청에 따라 유동적 조절 가능) final int initialVisibleCount = crossAxisCount; @@ -1042,17 +1441,14 @@ class _DashboardScreenState extends ConsumerState { visibleActivities = activities.take(initialVisibleCount).toList(); } - // 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려) - const spacing = 12.0; - final double cardWidth = - (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount; + final cardWidth = _dashboardCardWidth(maxWidth, crossAxisCount); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( - spacing: spacing, - runSpacing: spacing, + spacing: _dashboardCardSpacing, + runSpacing: _dashboardCardSpacing, children: visibleActivities.map((item) { return SizedBox( width: cardWidth, @@ -1098,7 +1494,7 @@ class _DashboardScreenState extends ConsumerState { Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) { final isActive = item.status == 'active'; - final statusColor = isActive ? Colors.green : Colors.grey; + final statusColor = _activityStatusColor(item.status); final borderColor = isActive ? Colors.green.withValues(alpha: 128) : _border; @@ -1110,10 +1506,10 @@ class _DashboardScreenState extends ConsumerState { // 카드 컨텐츠 final cardContent = Container( width: cardWidth ?? 260, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: _surface, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(12), border: Border.all(color: borderColor, width: borderWidth), boxShadow: isActive ? [ @@ -1128,38 +1524,8 @@ class _DashboardScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Text( - item.appName, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: _ink, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: statusColor, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - item.status == 'active' - ? tr('ui.userfront.dashboard.activity.linked') - : tr('ui.userfront.dashboard.status.revoked'), - style: const TextStyle( - fontSize: 11, - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 12), + _buildActivityCardHeader(item, statusColor), + const SizedBox(height: 10), Text( tr('ui.userfront.dashboard.last_auth_label'), style: TextStyle(fontSize: 12, color: Colors.grey[600]), @@ -1167,13 +1533,13 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(height: 4), Text( item.lastAuthAt, - style: const TextStyle( - fontSize: 14, + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600, color: _ink, ), ), - const SizedBox(height: 16), + const SizedBox(height: 14), Row( children: [ Expanded( @@ -1181,8 +1547,8 @@ class _DashboardScreenState extends ConsumerState { onPressed: () => _showRpDetails(item), style: OutlinedButton.styleFrom( foregroundColor: _ink, - side: const BorderSide(color: _border), - padding: const EdgeInsets.symmetric(vertical: 8), + side: BorderSide(color: _border), + padding: const EdgeInsets.symmetric(vertical: 7), ), child: Text( tr('ui.common.details'), @@ -1204,7 +1570,7 @@ class _DashboardScreenState extends ConsumerState { color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5, ), - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 7), ), child: _isRevoking && !item.isRevoked ? const SizedBox( @@ -1241,7 +1607,7 @@ class _DashboardScreenState extends ConsumerState { cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () async { - final itemUrl = item.url; + final itemUrl = item.launchUrl; if (itemUrl != null && itemUrl.isNotEmpty) { final uri = Uri.parse(itemUrl); final canOpen = await canLaunchUrl(uri); @@ -1264,47 +1630,277 @@ class _DashboardScreenState extends ConsumerState { return opaqueCard; } + Widget _buildActivityCardHeader(_ActivityItem item, Color statusColor) { + final statusBadge = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + item.status == 'active' + ? tr('ui.userfront.dashboard.activity.linked') + : tr('ui.userfront.dashboard.status.revoked'), + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ); + + return SizedBox( + height: 40, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (item.logo.isNotEmpty) ...[ + _buildActivityLogo(item.logo), + const SizedBox(width: 10), + ], + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + item.appName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _ink, + height: 1.25, + ), + ), + ), + ), + const SizedBox(width: 8), + statusBadge, + ], + ), + ); + } + + Widget _buildActivityLogo(String logoUrl) { + return SizedBox( + width: 40, + height: 40, + child: _buildActivityLogoImage(logoUrl), + ); + } + + Widget _buildActivityLogoImage(String logoUrl) { + final isSvg = _isSvgLogoUrl(logoUrl); + return isSvg + ? SvgPicture.network( + logoUrl, + fit: BoxFit.contain, + placeholderBuilder: (context) => _buildActivityLogoLoading(), + ) + : Image.network( + logoUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return _buildActivityLogoFallback(); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + return _buildActivityLogoLoading(); + }, + ); + } + + bool _isSvgLogoUrl(String logoUrl) { + final normalized = logoUrl.trim().toLowerCase(); + if (normalized.isEmpty) { + return false; + } + final uri = Uri.tryParse(normalized); + final path = uri?.path.toLowerCase() ?? normalized; + return path.endsWith('.svg'); + } + + Widget _buildActivityLogoLoading() { + return Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey[400], + ), + ), + ); + } + + Widget _buildActivityLogoFallback() { + return Icon(Icons.apps_rounded, size: 20, color: Colors.grey[500]); + } + Widget _buildAccessHistory(AuthTimelineState state, bool isWide) { + final sessionsState = ref.watch(userSessionsProvider); if (state.isLoading && state.items.isEmpty) { return _buildHistoryContainer( - child: const Center(child: CircularProgressIndicator()), + child: const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ), ); } if (state.error != null && state.items.isEmpty) { return _buildHistoryContainer( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(tr('msg.userfront.dashboard.audit_load_error')), - const SizedBox(height: 8), - TextButton( - onPressed: () => - ref.read(authTimelineProvider.notifier).refresh(), - child: Text(tr('ui.common.retry')), - ), - ], + child: SizedBox( + height: 120, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(tr('msg.userfront.dashboard.audit_load_error')), + const SizedBox(height: 8), + TextButton( + onPressed: () => + ref.read(authTimelineProvider.notifier).refresh(), + child: Text(tr('ui.common.retry')), + ), + ], + ), ), ), ); } - if (state.items.isEmpty) { + if (sessionsState.isLoading && !sessionsState.hasValue) { return _buildHistoryContainer( - child: Center( - child: Text( - tr('msg.userfront.dashboard.audit_empty'), - style: TextStyle(color: Colors.grey[600]), + child: const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ), + ); + } + + if (sessionsState.hasError && !sessionsState.hasValue) { + return _buildHistoryContainer( + child: SizedBox( + height: 120, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(tr('msg.userfront.dashboard.sessions.error')), + const SizedBox(height: 8), + TextButton( + onPressed: () => + ref.read(userSessionsProvider.notifier).refresh(), + child: Text(tr('ui.common.retry')), + ), + ], + ), ), ), ); } + final sessions = sessionsState is AsyncData> + ? sessionsState.value + : const []; + final Map sessionById = { + for (final session in sessions) session.sessionId.trim(): session, + }; + final filteredItems = state.items.where((log) { + if (!_showActiveSessionsOnly) { + return true; + } + final status = _historySessionStatusForLog(log, sessionById); + return status != _HistorySessionStatus.inactive; + }).toList(); + + if (filteredItems.isEmpty) { + return _buildHistoryContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHistoryHeader(), + const SizedBox(height: 20), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + _showActiveSessionsOnly + ? tr('msg.userfront.audit.filtered_empty') + : tr('msg.userfront.dashboard.audit_empty'), + style: TextStyle(color: Colors.grey[600]), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + } + if (isWide) { - return _buildHistoryTable(state); + return _buildHistoryTable(state, filteredItems, sessionById); } - return _buildHistoryList(state); + return _buildHistoryList(state, filteredItems, sessionById); + } + + Widget _buildHistoryHeader() { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr('ui.userfront.audit.filter.title'), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: _ink, + ), + ), + const SizedBox(height: 4), + Text( + tr('msg.userfront.audit.filter.description'), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + const SizedBox(width: 16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tr('ui.userfront.audit.filter.toggle_label'), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _ink, + ), + ), + const SizedBox(width: 2), + Transform.scale( + scale: 0.84, + alignment: Alignment.centerRight, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _showActiveSessionsOnly, + onChanged: (value) { + setState(() { + _showActiveSessionsOnly = value; + }); + }, + ), + ), + ], + ), + ], + ); } Widget _buildHistoryContainer({required Widget child}) { @@ -1320,10 +1916,162 @@ class _DashboardScreenState extends ConsumerState { ); } - Widget _buildHistoryTable(AuthTimelineState state) { + _HistorySessionStatus _historySessionStatusForLog( + AuditLogEntry log, + Map sessionById, + ) { + final sessionId = log.sessionId.trim(); + if (sessionId.isEmpty) { + return _HistorySessionStatus.inactive; + } + final session = sessionById[sessionId]; + if (session == null) { + return _HistorySessionStatus.inactive; + } + if (session.isCurrent) { + return _HistorySessionStatus.current; + } + if (session.isActive) { + return _HistorySessionStatus.active; + } + return _HistorySessionStatus.inactive; + } + + String _historySessionStatusLabel(_HistorySessionStatus status) { + switch (status) { + case _HistorySessionStatus.current: + return tr('ui.userfront.dashboard.sessions.current_badge'); + case _HistorySessionStatus.active: + return tr('ui.userfront.dashboard.sessions.active_badge'); + case _HistorySessionStatus.inactive: + return tr('ui.common.status.inactive'); + } + } + + Color _historySessionStatusColor(_HistorySessionStatus status) { + switch (status) { + case _HistorySessionStatus.current: + return Colors.blueGrey; + case _HistorySessionStatus.active: + return Colors.green; + case _HistorySessionStatus.inactive: + return Colors.grey; + } + } + + Widget _buildHistoryStatusBadge(_HistorySessionStatus status) { + return SizedBox( + width: _historyStatusColumnWidth, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _historySessionStatusColor(status), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + _historySessionStatusLabel(status), + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } + + Widget _buildHistorySessionActionCell(UserSessionSummary? session) { + if (session == null) { + return SizedBox( + width: _historyActionColumnWidth, + child: Center( + child: _selectableText(tr('ui.common.hyphen', fallback: '-')), + ), + ); + } + final isCurrent = session.isCurrent; + final canRevoke = + !isCurrent && _revokingSessionId == null && session.isActive; + return SizedBox( + width: _historyActionColumnWidth, + child: OutlinedButton( + onPressed: canRevoke ? () => _onRevokeSession(session) : null, + style: OutlinedButton.styleFrom( + foregroundColor: canRevoke ? Colors.redAccent : Colors.grey, + side: BorderSide( + color: canRevoke ? Colors.redAccent : Colors.grey, + width: 0.6, + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: _revokingSessionId == session.sessionId + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.redAccent, + ), + ) + : Text( + isCurrent + ? tr('ui.userfront.dashboard.sessions.current_disabled') + : session.isActive + ? tr('ui.userfront.dashboard.sessions.revoke.action') + : tr('ui.common.hyphen', fallback: '-'), + ), + ), + ); + } + + int _dashboardCardColumnCount(double maxWidth) { + if (maxWidth > 1200) { + return 4; + } + if (maxWidth > 800) { + return 3; + } + return 2; + } + + double _dashboardCardWidth(double maxWidth, int crossAxisCount) { + return math.min( + (maxWidth - (_dashboardCardSpacing * (crossAxisCount - 1))) / + crossAxisCount, + _dashboardCardMaxWidth, + ); + } + + Color _activityStatusColor(String status) { + return status == 'active' ? Colors.green : Colors.grey; + } + + Widget _buildCenteredHistoryHeader(String label, {double? width}) { + return SizedBox( + width: width, + child: Center(child: Text(label, textAlign: TextAlign.center)), + ); + } + + Widget _buildCenteredHistoryCell(Widget child, {double? width}) { + return SizedBox( + width: width, + child: Center(child: child), + ); + } + + Widget _buildHistoryTable( + AuthTimelineState state, + List items, + Map sessionById, + ) { return _buildHistoryContainer( child: Column( children: [ + _buildHistoryHeader(), + const SizedBox(height: 16), LayoutBuilder( builder: (context, constraints) { final sessionColumnWidth = _historySessionColumnWidth( @@ -1338,41 +2086,75 @@ class _DashboardScreenState extends ConsumerState { horizontalMargin: 12, columns: [ DataColumn( - label: SizedBox( - width: sessionColumnWidth, - child: Text( - tr( - 'ui.userfront.audit.table.session_id', - fallback: 'Session ID', - ), + label: _buildCenteredHistoryHeader( + tr( + 'ui.userfront.audit.table.session_id', + fallback: 'Session ID', ), + width: sessionColumnWidth, ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.date')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.date'), + width: _historyDateColumnWidth, + ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.app')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.app'), + width: _historyAppColumnWidth, + ), ), DataColumn( - label: Text( + label: _buildCenteredHistoryHeader( tr('ui.userfront.audit.table.ip', fallback: 'IP'), + width: _historyIpColumnWidth, ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.device')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.device'), + width: _historyDeviceColumnWidth, + ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.auth_method')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.browser'), + width: _historyBrowserColumnWidth, + ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.result')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.auth_method'), + width: _historyAuthMethodColumnWidth, + ), ), DataColumn( - label: Text(tr('ui.userfront.audit.table.status')), + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.result'), + width: _historyResultColumnWidth, + ), + ), + DataColumn( + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.status'), + width: _historyStatusColumnWidth, + ), + ), + DataColumn( + label: _buildCenteredHistoryHeader( + tr('ui.userfront.audit.table.action'), + width: _historyActionColumnWidth, + ), ), ], - rows: state.items.map((log) { + rows: items.map((log) { + final matchedSession = sessionById[log.sessionId.trim()]; + final sessionStatus = _historySessionStatusForLog( + log, + sessionById, + ); final statusLabel = log.status == 'success' ? tr('ui.common.status.success') : tr('ui.common.status.failure'); @@ -1385,45 +2167,86 @@ class _DashboardScreenState extends ConsumerState { final deviceLabel = _deviceLabelFromUserAgent( log.userAgent, ); + final browserLabel = _sessionBrowserLabel(log.userAgent); return DataRow( cells: [ DataCell( - SizedBox( - width: sessionColumnWidth, - child: _buildHistorySessionIdCell( + _buildCenteredHistoryCell( + _buildHistorySessionIdCell( log.sessionId.isEmpty ? tr('ui.common.hyphen', fallback: '-') : log.sessionId, sessionColumnWidth, ), + width: sessionColumnWidth, ), ), DataCell( - _selectableText(_formatDateTime(log.timestamp)), - ), - DataCell(_buildAppCell(log)), - DataCell( - _selectableText( - log.ipAddress.isEmpty - ? tr('ui.common.hyphen', fallback: '-') - : log.ipAddress, + _buildCenteredHistoryCell( + _selectableText(_formatDateTime(log.timestamp)), + width: _historyDateColumnWidth, ), ), - DataCell(_selectableText(deviceLabel)), - DataCell(_buildAuthMethodCell(log, authMethod)), DataCell( - _selectableText( - statusLabel, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.w600, + _buildCenteredHistoryCell( + _buildAppCell(log), + width: _historyAppColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _selectableText( + log.ipAddress.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : log.ipAddress, ), + width: _historyIpColumnWidth, ), ), DataCell( - _selectableText( - tr('ui.userfront.audit.table.pending'), - style: const TextStyle(color: Colors.grey), + _buildCenteredHistoryCell( + _singleLineText(deviceLabel), + width: _historyDeviceColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _selectableText( + browserLabel.isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : browserLabel, + ), + width: _historyBrowserColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _buildAuthMethodCell(log, authMethod), + width: _historyAuthMethodColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _selectableText( + statusLabel, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + width: _historyResultColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _buildHistoryStatusBadge(sessionStatus), + width: _historyStatusColumnWidth, + ), + ), + DataCell( + _buildCenteredHistoryCell( + _buildHistorySessionActionCell(matchedSession), + width: _historyActionColumnWidth, ), ), ], @@ -1451,6 +2274,10 @@ class _DashboardScreenState extends ConsumerState { } String _compactSessionId(String sessionId) { + final parts = sessionId.split('-'); + if (parts.length >= 4) { + return '${parts.take(3).join('-')}-...'; + } if (sessionId.length <= _historySessionMinVisibleChars) { return sessionId; } @@ -1458,26 +2285,32 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) { - final compactMode = columnWidth <= _historySessionMinWidth + 0.5; - final displayText = compactMode ? _compactSessionId(sessionId) : sessionId; + final displayText = _compactSessionId(sessionId); final textWidget = Text( displayText, maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, ); - if (displayText == sessionId) { + if (displayText == sessionId || sessionId.isEmpty) { return textWidget; } return Tooltip(message: sessionId, child: textWidget); } - Widget _buildHistoryList(AuthTimelineState state) { + Widget _buildHistoryList( + AuthTimelineState state, + List items, + Map sessionById, + ) { return _buildHistoryContainer( child: Column( children: [ - for (final log in state.items) + _buildHistoryHeader(), + const SizedBox(height: 16), + for (final log in items) Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), @@ -1489,12 +2322,21 @@ class _DashboardScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + _buildHistoryStatusBadge( + _historySessionStatusForLog(log, sessionById), + ), + const Spacer(), + ], + ), + const SizedBox(height: 8), Row( children: [ Expanded( child: _buildAppCell( log, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.w600, color: _ink, ), @@ -1515,10 +2357,10 @@ class _DashboardScreenState extends ConsumerState { ), const SizedBox(height: 6), _selectableText( - tr( + _renderTranslatedText( 'msg.userfront.audit.session_id', fallback: 'Session ID: {{value}}', - params: { + values: { 'value': log.sessionId.isEmpty ? tr('ui.common.hyphen', fallback: '-') : log.sessionId, @@ -1549,6 +2391,16 @@ class _DashboardScreenState extends ConsumerState { }, ), ), + _selectableText( + tr( + 'msg.userfront.audit.browser', + params: { + 'value': _sessionBrowserLabel(log.userAgent).isEmpty + ? tr('ui.common.hyphen', fallback: '-') + : _sessionBrowserLabel(log.userAgent), + }, + ), + ), _buildAuthMethodLine( log, log.authMethod.isNotEmpty @@ -1566,8 +2418,18 @@ class _DashboardScreenState extends ConsumerState { ), ), _selectableText( - tr('msg.userfront.audit.status'), - style: TextStyle(color: Colors.grey[600]), + tr( + 'msg.userfront.audit.status', + params: { + 'value': _historySessionStatusLabel( + _historySessionStatusForLog(log, sessionById), + ), + }, + ), + ), + const SizedBox(height: 12), + _buildHistorySessionActionCell( + sessionById[log.sessionId.trim()], ), ], ), @@ -1684,12 +2546,16 @@ class _DashboardScreenState extends ConsumerState { } } +enum _HistorySessionStatus { current, active, inactive } + class _ActivityItem { final String clientId; final String appName; + final String logo; final String lastAuthAt; final String status; final String? url; + final String? launchUrl; final List scopes; final bool isRevoked; final VoidCallback? onRevoke; @@ -1698,10 +2564,12 @@ class _ActivityItem { _ActivityItem({ required this.clientId, required this.appName, + required this.logo, required this.lastAuthAt, required this.status, required this.scopes, this.url, + this.launchUrl, this.isRevoked = false, this.onRevoke, this.lastAuthDateTime, diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 80b80ae3..b1b2a04e 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -3,13 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:userfront/i18n.dart'; -import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/services/auth_proxy_service.dart'; -import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/logout_service.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; import '../../../../core/widgets/language_selector.dart'; +import '../../../../core/widgets/theme_toggle_button.dart'; import '../../data/models/user_profile_model.dart'; import '../../domain/notifiers/profile_notifier.dart'; @@ -21,10 +21,6 @@ class ProfilePage extends ConsumerStatefulWidget { } class _ProfilePageState extends ConsumerState { - static const _ink = Color(0xFF1A1F2C); - static const _surface = Colors.white; - static const _border = Color(0xFFE5E7EB); - static const _subtle = Color(0xFFF7F8FA); static final _log = Logger('ProfilePage'); UserProfile? _cachedProfile; @@ -55,9 +51,27 @@ class _ProfilePageState extends ConsumerState { bool _showCurrentPassword = false; bool _showNewPassword = false; bool _showConfirmPassword = false; + bool _isDesktopSideMenuOpen = true; Map? _passwordPolicy; bool _isPasswordPolicyLoading = false; + Color get _ink => Theme.of(context).colorScheme.onSurface; + Color get _surface => Theme.of(context).colorScheme.surface; + Color get _border => Theme.of(context).colorScheme.outlineVariant; + Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest; + + String _renderTranslatedText( + String key, { + String? fallback, + Map values = const {}, + }) { + var text = tr(key, fallback: fallback); + values.forEach((name, value) { + text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value); + }); + return text; + } + @override void initState() { super.initState(); @@ -99,16 +113,16 @@ class _ProfilePageState extends ConsumerState { final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true; final parts = [ - tr( + _renderTranslatedText( 'msg.userfront.signup.policy.min_length', - params: {'count': '$minLength'}, + values: {'count': '$minLength'}, ), ]; if (minTypes > 0) { parts.add( - tr( + _renderTranslatedText( 'msg.userfront.signup.policy.min_types', - params: {'count': '$minTypes'}, + values: {'count': '$minTypes'}, ), ); } @@ -125,9 +139,9 @@ class _ProfilePageState extends ConsumerState { parts.add(tr('msg.userfront.signup.policy.symbol')); } - return tr( + return _renderTranslatedText( 'msg.userfront.signup.policy.summary', - params: {'rules': parts.join(", ")}, + values: {'rules': parts.join(", ")}, ); } @@ -164,8 +178,7 @@ class _ProfilePageState extends ConsumerState { } Future _logout() async { - AuthTokenStore.clear(); - AuthNotifier.instance.notify(); + await LogoutService().logout(); } void _ensureControllers(UserProfile profile) { @@ -605,7 +618,14 @@ class _ProfilePageState extends ConsumerState { ), const Padding( padding: EdgeInsets.only(bottom: 16), - child: LanguageSelector(compact: true), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ThemeToggleButton(), + SizedBox(height: 8), + LanguageSelector(compact: true), + ], + ), ), ], ); @@ -617,7 +637,7 @@ class _ProfilePageState extends ConsumerState { children: [ Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: _ink, @@ -644,7 +664,7 @@ class _ProfilePageState extends ConsumerState { const SizedBox(width: 6), Text( label, - style: const TextStyle( + style: TextStyle( fontSize: 12, color: _ink, fontWeight: FontWeight.w600, @@ -690,8 +710,12 @@ class _ProfilePageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - tr('msg.userfront.profile.greeting', params: {'name': name}), - style: const TextStyle( + _renderTranslatedText( + 'msg.userfront.profile.greeting', + fallback: 'Hello, {{name}}.', + values: {'name': name}, + ), + style: TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: _ink, @@ -982,12 +1006,17 @@ class _ProfilePageState extends ConsumerState { const SizedBox(height: 8), Text( tr('msg.userfront.profile.password.subtitle'), - style: const TextStyle(color: Color(0xFF6B7280)), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 8), Text( _buildPasswordPolicyDescription(), - style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), ), const SizedBox(height: 16), TextField( @@ -1217,14 +1246,35 @@ class _ProfilePageState extends ConsumerState { return Scaffold( backgroundColor: _subtle, appBar: AppBar( + leading: isWide + ? IconButton( + icon: Icon( + _isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu, + ), + tooltip: _isDesktopSideMenuOpen + ? tr('ui.common.collapse') + : '펼치기', + onPressed: () { + setState(() { + _isDesktopSideMenuOpen = !_isDesktopSideMenuOpen; + }); + }, + ) + : Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + tooltip: MaterialLocalizations.of( + context, + ).openAppDrawerTooltip, + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), title: Text( tr('ui.userfront.app_title'), style: const TextStyle(fontWeight: FontWeight.bold), ), - elevation: 0, - backgroundColor: _surface, - foregroundColor: Colors.black, actions: [ + const ThemeToggleButton(compact: true), IconButton( icon: const Icon(Icons.home_outlined), tooltip: tr('ui.userfront.nav.dashboard'), @@ -1245,7 +1295,8 @@ class _ProfilePageState extends ConsumerState { drawer: isWide ? null : Drawer(child: _buildSideMenu(context)), body: Row( children: [ - if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)), + if (isWide && _isDesktopSideMenuOpen) + SizedBox(width: 240, child: _buildSideMenu(context)), Expanded(child: _buildContent(profile, isUpdating)), ], ), diff --git a/userfront/lib/i18n_data.dart b/userfront/lib/i18n_data.dart index ced42124..f28934f8 100644 --- a/userfront/lib/i18n_data.dart +++ b/userfront/lib/i18n_data.dart @@ -54,20 +54,24 @@ const Map koStrings = { "msg.admin.api_keys.create.success.notice": "아래의 비밀번호(Secret)는 보안을 위해 ", "msg.admin.api_keys.create.success.notice_emphasis": "지금 한 번만", "msg.admin.api_keys.create.success.notice_suffix": "표시됩니다.", - "msg.admin.api_keys.list.delete_confirm": "API 키 \"{{name}}\"를 삭제할까요?", + "msg.admin.api_keys.list.delete_confirm": + "API 키 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?", "msg.admin.api_keys.list.empty": "등록된 API 키가 없습니다.", "msg.admin.api_keys.list.fetch_error": "API 키 목록 조회에 실패했습니다.", "msg.admin.api_keys.list.registry.count": "총 {{count}}개 API 키", "msg.admin.api_keys.list.subtitle": "서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.", "msg.admin.audit.empty": "아직 수집된 감사 로그가 없습니다.", - "msg.admin.audit.end": "End of audit feed", + "msg.admin.audit.end": "감사 로그의 마지막입니다.", "msg.admin.audit.filters.empty": "필터 없음", - "msg.admin.audit.load_error": "Error loading logs: {{error}}", - "msg.admin.audit.loading": "Loading audit logs...", + "msg.admin.audit.load_error": "감사 로그를 불러오지 못했습니다: {{error}}", + "msg.admin.audit.loading": "감사 로그를 불러오는 중...", "msg.admin.audit.registry.count": "로드된 로그 {{count}}건", "msg.admin.audit.subtitle": "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.", + "msg.admin.common.forbidden": "이 작업을 수행할 권한이 없습니다.", + "msg.admin.groups.create.description": "부서나 팀과 같은 새로운 조직 단위를 추가합니다.", + "msg.admin.groups.create.title": "새 조직 단위 생성", "msg.admin.groups.list.create_error": "생성 실패", "msg.admin.groups.list.create_success": "조직 단위가 생성되었습니다.", "msg.admin.groups.list.delete_confirm": "정말로 삭제하시겠습니까?", @@ -88,8 +92,7 @@ const Map koStrings = { "msg.admin.groups.roles.assign_success": "역할이 할당되었습니다.", "msg.admin.groups.roles.description": "이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.", "msg.admin.groups.roles.empty": "할당된 역할이 없습니다.", - "msg.admin.groups.roles.remove_confirm": - "msg.admin.groups.roles.remove_confirm", + "msg.admin.groups.roles.remove_confirm": "역할을 회수하시겠습니까?", "msg.admin.groups.roles.remove_success": "역할이 회수되었습니다.", "msg.admin.header.subtitle": "Tenant isolation & least privilege by default", "msg.admin.idp_env_prod": "IDP env: prod", @@ -97,6 +100,10 @@ const Map koStrings = { "msg.admin.notice.idp_policy": "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다.", "msg.admin.notice.scope": "관리 기능은 /admin 네임스페이스에서만 노출합니다.", + "msg.admin.org.hover_member_info": "마우스를 올리면 상세 정보를 확인할 수 있습니다.", + "msg.admin.org.import_description": "CSV 파일을 업로드하여 조직도를 일괄 등록합니다.", + "msg.admin.org.import_error": "조직도 임포트 중 오류가 발생했습니다.", + "msg.admin.org.import_success": "조직도가 성공적으로 임포트되었습니다.", "msg.admin.overview.description": "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.", "msg.admin.overview.idp_fallback": "Fallback: Descope", "msg.admin.overview.idp_primary": "IDP: Ory primary", @@ -109,18 +116,24 @@ const Map koStrings = { "Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.", "msg.admin.overview.playbook.tenant_title": "Tenant isolation", "msg.admin.overview.quick_links.description": "주요 운영 화면으로 바로 이동합니다.", + "msg.admin.overview.summary.audit_events_24h": "최근 24시간 감사 로그", + "msg.admin.overview.summary.oidc_clients": "등록된 OIDC 클라이언트", + "msg.admin.overview.summary.policy_gate": "정책 가이트 상태", + "msg.admin.overview.summary.total_tenants": "전체 테넌트 수", "msg.admin.scope_admin": "Scoped to /admin", "msg.admin.session_ttl": "Session TTL: 15m admin", "msg.admin.tenant_headers": "Tenant-aware headers", "msg.admin.tenants.admins.add_success": "관리자가 추가되었습니다.", "msg.admin.tenants.admins.empty": "등록된 관리자가 없습니다.", "msg.admin.tenants.admins.remove_confirm": "관리자를 삭제하시겠습니까?", + "msg.admin.tenants.admins.remove_last": "마지막 관리자는 회수할 수 없습니다.", + "msg.admin.tenants.admins.remove_self": "본인의 권한은 회수할 수 없습니다.", "msg.admin.tenants.admins.remove_success": "권한이 회수되었습니다.", "msg.admin.tenants.admins.subtitle": "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", "msg.admin.tenants.approve_confirm": "이 테넌트를 승인하시겠습니까?", "msg.admin.tenants.approve_success": "테넌트가 승인되었습니다.", "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": "생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.", "msg.admin.tenants.create.memo.subtitle": @@ -128,26 +141,51 @@ const Map koStrings = { "msg.admin.tenants.create.profile.subtitle": "필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.", "msg.admin.tenants.create.subtitle": "글로벌 운영 기준의 신규 테넌트를 등록합니다.", - "msg.admin.tenants.delete_confirm": "테넌트 \"{{name}}\"를 삭제할까요?", + "msg.admin.tenants.delete_confirm": "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?", "msg.admin.tenants.delete_success": "테넌트가 삭제되었습니다.", "msg.admin.tenants.empty": "아직 등록된 테넌트가 없습니다.", "msg.admin.tenants.fetch_error": "테넌트 목록 조회에 실패했습니다.", + "msg.admin.tenants.members.desc": "조직에 소속된 사용자 목록을 확인합니다.", "msg.admin.tenants.members.empty": "소속된 사용자가 없습니다.", + "msg.admin.tenants.members.limit_notice": "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다.", "msg.admin.tenants.missing_id": "테넌트 ID가 없습니다.", + "msg.admin.tenants.not_found": "테넌트를 찾을 수 없습니다.", + "msg.admin.tenants.owners.add_success": "소유자가 추가되었습니다.", + "msg.admin.tenants.owners.empty": "등록된 소유자가 없습니다.", + "msg.admin.tenants.owners.remove_confirm": "소유자를 삭제하시겠습니까?", + "msg.admin.tenants.owners.remove_last": "마지막 소유자는 회수할 수 없습니다.", + "msg.admin.tenants.owners.remove_self": "본인의 권한은 회수할 수 없습니다.", + "msg.admin.tenants.owners.remove_success": "소유자 권한이 회수되었습니다.", + "msg.admin.tenants.owners.subtitle": "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.", "msg.admin.tenants.registry.count": "총 {{count}}개 테넌트", - "msg.admin.tenants.schema.empty": - "No custom fields defined. Click \"Add Field\" to begin.", - "msg.admin.tenants.schema.missing_id": "Tenant ID missing", - "msg.admin.tenants.schema.subtitle": - "Define custom attributes for users in this tenant.", - "msg.admin.tenants.schema.update_error": "Failed to update schema", - "msg.admin.tenants.schema.update_success": "Schema updated successfully", + "msg.admin.tenants.remove_sub_confirm": + "테넌트 \\\\\\\"{{name}}\\\\\\\"을(를) 하위 조직에서 제외할까요?", + "msg.admin.tenants.schema.empty": "등록된 커스텀 필드가 없습니다. 필드 추가를 눌러 시작하세요.", + "msg.admin.tenants.schema.forbidden_desc": "사용자 스키마 설정은 관리자만 접근할 수 있습니다.", + "msg.admin.tenants.schema.missing_id": "테넌트 ID가 없습니다.", + "msg.admin.tenants.schema.subtitle": "이 테넌트의 사용자에게 적용할 커스텀 속성을 정의합니다.", + "msg.admin.tenants.schema.update_error": "스키마 업데이트에 실패했습니다.", + "msg.admin.tenants.schema.update_success": "스키마가 성공적으로 업데이트되었습니다.", "msg.admin.tenants.sub.empty": "하위 테넌트가 없습니다.", "msg.admin.tenants.sub.subtitle": "현재 테넌트 하위에 생성된 조직입니다.", "msg.admin.tenants.subtitle": "현재 등록된 테넌트를 확인하고 상태를 관리합니다.", + "msg.admin.users.bulk.delete_confirm": "선택한 {{count}}명의 사용자를 정말로 삭제하시겠습니까?", + "msg.admin.users.bulk.delete_success": "{{count}}명의 사용자가 삭제되었습니다.", + "msg.admin.users.bulk.description": "CSV 파일을 통해 사용자를 일괄 등록하거나 관리합니다.", + "msg.admin.users.bulk.move_description": "선택한 사용자를 다른 테넌트로 일괄 이동합니다.", + "msg.admin.users.bulk.move_error": "사용자 이동 중 오류가 발생했습니다.", + "msg.admin.users.bulk.move_success": "{{count}}명의 사용자가 성공적으로 이동되었습니다.", + "msg.admin.users.bulk.parsed_count": "{{count}}행의 데이터가 파싱되었습니다.", + "msg.admin.users.bulk.schema_incompatible": "대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:", + "msg.admin.users.bulk.schema_missing": "대상 테넌트의 필수 필드가 누락되어 있습니다:", + "msg.admin.users.bulk.update_success": "사용자 정보가 일괄 업데이트되었습니다.", "msg.admin.users.create.account.subtitle": "새로운 사용자를 시스템에 등록합니다.", "msg.admin.users.create.error": "사용자 생성에 실패했습니다.", "msg.admin.users.create.form.email_required": "이메일은 필수입니다.", + "msg.admin.users.create.form.field_invalid": "{{label}} 형식이 올바르지 않습니다.", + "msg.admin.users.create.form.field_required": "{{label}}은(는) 필수입니다.", + "msg.admin.users.create.form.login_id_help": + "msg.admin.users.create.form.login_id_help", "msg.admin.users.create.form.name_required": "이름은 필수입니다.", "msg.admin.users.create.form.password_auto_help": "비워두면 시스템이 초기 비밀번호를 자동 생성합니다.", @@ -157,45 +195,86 @@ const Map koStrings = { "msg.admin.users.create.password_generated.with_email": "{{email}} 계정의 초기 비밀번호입니다.", "msg.admin.users.create.password_required": "비밀번호를 입력하거나 자동 생성을 사용해 주세요.", + "msg.admin.users.create.success": "사용자가 성공적으로 생성되었습니다.", + "msg.admin.users.detail.delete_confirm": "삭제하시겠습니까?", + "msg.admin.users.detail.delete_error": "msg.admin.users.detail.delete_error", + "msg.admin.users.detail.delete_success": "사용자가 삭제되었습니다.", "msg.admin.users.detail.edit_subtitle": "{{email}} 계정의 정보를 수정합니다.", + "msg.admin.users.detail.form.field_required": "필수입니다.", + "msg.admin.users.detail.form.invalid_format": "형식이 올바르지 않습니다.", "msg.admin.users.detail.form.name_required": "이름은 필수입니다.", + "msg.admin.users.detail.history_desc": "최근 로그인한 연동 서비스(RP) 목록입니다.", + "msg.admin.users.detail.no_history": "아직 이용한 서비스가 없습니다.", + "msg.admin.users.detail.no_tenants": "소속된 테넌트 정보가 없습니다.", "msg.admin.users.detail.not_found": "사용자를 찾을 수 없습니다.", + "msg.admin.users.detail.password_generated": "안전한 비밀번호가 생성되었습니다.", + "msg.admin.users.detail.password_generated_help": + "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다.", + "msg.admin.users.detail.password_manual_required": "비밀번호를 입력해 주세요.", + "msg.admin.users.detail.reset_auto_desc": + "해킹이 어려운 복잡한 임시 비밀번호를 시스템이 즉시 생성합니다.", + "msg.admin.users.detail.reset_password_confirm": + "msg.admin.users.detail.reset_password_confirm", + "msg.admin.users.detail.reset_password_help": + "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.", "msg.admin.users.detail.security.password_hint": "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.", + "msg.admin.users.detail.security_desc": "비밀번호 초기화 및 보안 설정을 관리합니다.", + "msg.admin.users.detail.self_password_reset_blocked": + "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.", + "msg.admin.users.detail.tenant_slug_help": "사용자의 주된 정체성을 결정하는 대표 조직을 지정합니다.", + "msg.admin.users.detail.tenants_desc": "각 테넌트별로 정의된 커스텀 스키마 정보를 관리합니다.", "msg.admin.users.detail.update_error": "사용자 수정에 실패했습니다.", "msg.admin.users.detail.update_success": "사용자 정보가 수정되었습니다.", - "msg.admin.users.list.delete_confirm": "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?", + "msg.admin.users.list.columns.description": "테이블에 표시할 컬럼을 선택합니다.", + "msg.admin.users.list.columns.no_custom": "이 테넌트에 정의된 커스텀 필드가 없습니다.", + "msg.admin.users.list.delete_confirm": + "사용자 \\\\\\\"{{name}}\\\\\\\"을(를) 정말 삭제하시겠습니까?", "msg.admin.users.list.empty": "검색 결과가 없습니다.", "msg.admin.users.list.fetch_error": "사용자 목록 조회에 실패했습니다.", "msg.admin.users.list.registry.count": "총 {{count}}명의 사용자가 등록되어 있습니다.", "msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다. (Local DB)", + "msg.common.copied": "복사되었습니다.", + "msg.common.copied_to_clipboard": "클립보드에 복사되었습니다.", "msg.common.error": "오류가 발생했습니다.", + "msg.common.forbidden": "접근 권한이 없습니다.", "msg.common.loading": "로딩 중...", "msg.common.no_description": "설명이 없습니다.", + "msg.common.parsing": "데이터 파싱 중...", "msg.common.requesting": "요청 중...", "msg.common.saving": "저장 중...", - "msg.common.unknown_error": "unknown error", - "msg.dev.clients.consents.empty": "No consents found.", - "msg.dev.clients.consents.load_error": "Error loading consents: {{error}}", - "msg.dev.clients.consents.loading": "Loading consents...", + "msg.common.unknown_error": "알 수 없는 오류", + "msg.dev.audit.empty": "조회된 감사 로그가 없습니다.", + "msg.dev.audit.forbidden": "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요.", + "msg.dev.audit.load_error": "감사 로그 조회 실패: {{error}}", + "msg.dev.audit.loaded_count": "로드된 로그 {{count}}건", + "msg.dev.audit.loading": "감사 로그를 불러오는 중...", + "msg.dev.audit.subtitle": "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다.", + "msg.dev.auth.access_denied_description": + "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요.", + "msg.dev.auth.access_denied_title": "접근 권한이 없습니다.", + "msg.dev.clients.consents.empty": "등록된 동의 내역이 없습니다.", + "msg.dev.clients.consents.load_error": "동의 내역을 불러오지 못했습니다: {{error}}", + "msg.dev.clients.consents.loading": "동의 내역을 불러오는 중...", + "msg.dev.clients.consents.revoke_confirm": + "정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.", "msg.dev.clients.consents.showing": - "Showing {{from}} to {{to}} of {{total}} users", + "전체 {{total}}명 중 {{from}}번째부터 {{to}}번째 사용자를 표시합니다.", "msg.dev.clients.consents.subtitle": "OIDC Relying Party 사용자 권한을 검토·관리합니다.", - "msg.dev.clients.copy_client_id": "Client ID가 복사되었습니다.", "msg.dev.clients.delete_confirm": "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "msg.dev.clients.delete_error": "삭제 실패: {{error}}", "msg.dev.clients.deleted": "앱이 삭제되었습니다.", "msg.dev.clients.details.copy_client_id": "Client ID가 복사되었습니다.", "msg.dev.clients.details.copy_client_secret": "Client Secret이 복사되었습니다.", "msg.dev.clients.details.copy_endpoint": "{{label}}가 복사되었습니다.", - "msg.dev.clients.details.load_error": "Error loading client: {{error}}", - "msg.dev.clients.details.loading": "Loading client...", + "msg.dev.clients.details.load_error": "앱 상세 정보를 불러오지 못했습니다: {{error}}", + "msg.dev.clients.details.loading": "앱 상세 정보를 불러오는 중...", "msg.dev.clients.details.missing_id": "Client ID가 필요합니다.", "msg.dev.clients.details.redirect.description": "인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.", "msg.dev.clients.details.redirect_saved": "Redirect URIs가 저장되었습니다.", "msg.dev.clients.details.rotate_confirm": - "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?", + "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\\\\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?", "msg.dev.clients.details.rotate_error": "재발급 실패: {{error}}", "msg.dev.clients.details.save_error": "저장 실패: {{error}}", "msg.dev.clients.details.secret_rotated": "Client Secret이 재발급되었습니다.", @@ -210,8 +289,88 @@ const Map koStrings = { "msg.dev.clients.federation.subtitle": "이 애플리케이션의 외부 IdP 설정을 관리합니다.", "msg.dev.clients.general.identity.logo_help": "인증 화면에 표시될 PNG/SVG URL입니다.", "msg.dev.clients.general.identity.subtitle": "앱 이름과 설명, 로고를 설정합니다.", - "msg.dev.clients.general.load_error": "Error loading client: {{error}}", - "msg.dev.clients.general.loading": "Loading client...", + "msg.dev.clients.general.load_error": "앱 설정을 불러오지 못했습니다: {{error}}", + "msg.dev.clients.general.loading": "앱 설정을 불러오는 중...", + "msg.dev.clients.general.public_key.allowed_algorithms_tooltip": + "허용 알고리즘: {{algorithms}}", + "msg.dev.clients.general.public_key.auth_method_client_secret_basic_help": + "일반적인 서버 사이드 앱 인증 방식입니다.", + "msg.dev.clients.general.public_key.auth_method_none_help": + "PKCE 기반 public client에 사용하는 방식입니다.", + "msg.dev.clients.general.public_key.auth_method_private_key_jwt_help": + "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다.", + "msg.dev.clients.general.public_key.cache.missing_algorithm_badge": + "알고리즘 미선언", + "msg.dev.clients.general.public_key.cache.missing_algorithm_reason": + "이 키는 `alg`가 비어 있어서 저장할 수 없습니다.", + "msg.dev.clients.general.public_key.cache.missing_algorithms_help": + "저장 전 JWKS 각 키에 `alg`를 명시해 주세요: {{details}}", + "msg.dev.clients.general.public_key.cache.missing_algorithms_title": + "알고리즘이 선언되지 않았습니다.", + "msg.dev.clients.general.public_key.cache.parsed_keys_empty": + "No parsed JWKS keys are available yet.", + "msg.dev.clients.general.public_key.cache.parsed_keys_help": + "Raw JWKS stays hidden. Only parsed key metadata is shown here.", + "msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason": + "이 알고리즘은 Headless Login에서 지원되지 않습니다.", + "msg.dev.clients.general.public_key.cache.unsupported_algorithms_help": + "저장 전 JWKS를 수정해 주세요: {{details}}", + "msg.dev.clients.general.public_key.cache.unsupported_algorithms_title": + "지원하지 않는 알고리즘이 감지되었습니다.", + "msg.dev.clients.general.public_key.cache_empty": + "아직 캐시된 JWKS가 없습니다. Refresh를 눌러 백엔드 캐시 상태를 조회하세요.", + "msg.dev.clients.general.public_key.cache_help": + "백엔드가 마지막으로 검증한 공개키 캐시 상태입니다.", + "msg.dev.clients.general.public_key.cache_refresh_failed": + "JWKS 캐시 새로고침에 실패했습니다: {{error}}", + "msg.dev.clients.general.public_key.cache_refreshed": "JWKS 캐시를 새로 고쳤습니다.", + "msg.dev.clients.general.public_key.cache_revoke_confirm": + "JWKS 캐시를 삭제하면 다음 검증 전에 다시 갱신해야 합니다. 계속할까요?", + "msg.dev.clients.general.public_key.cache_revoke_failed": + "JWKS 캐시 삭제에 실패했습니다: {{error}}", + "msg.dev.clients.general.public_key.cache_revoked": "JWKS 캐시를 삭제했습니다.", + "msg.dev.clients.general.public_key.guide_example": + "권장 예시: https://rp.example.com/.well-known/jwks.json", + "msg.dev.clients.general.public_key.guide_intro": + "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다.", + "msg.dev.clients.general.public_key.guide_step_1": + "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다.", + "msg.dev.clients.general.public_key.guide_step_2": + "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다.", + "msg.dev.clients.general.public_key.guide_step_3": + "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다.", + "msg.dev.clients.general.public_key.headless_help": + "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다.", + "msg.dev.clients.general.public_key.jwks_inline_help": + "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다.", + "msg.dev.clients.general.public_key.jwks_uri_help": + "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json", + "msg.dev.clients.general.public_key.request_object_alg_help": + "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다.", + "msg.dev.clients.general.public_key.source_help": + "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다.", + "msg.dev.clients.general.public_key.subtitle": + "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.", + "msg.dev.clients.general.public_key.validation.headless_requires_alg": + "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다.", + "msg.dev.clients.general.public_key.validation.headless_requires_private_key_jwt": + "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다.", + "msg.dev.clients.general.public_key.validation.headless_requires_public_key": + "Headless Login을 사용하려면 JWKS URI가 필요합니다.", + "msg.dev.clients.general.public_key.validation.invalid_jwks_inline": + "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다.", + "msg.dev.clients.general.public_key.validation.invalid_jwks_uri": + "JWKS URI 형식이 올바르지 않습니다.", + "msg.dev.clients.general.public_key.validation.missing_jwks_inline": + "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다.", + "msg.dev.clients.general.public_key.validation.missing_jwks_uri": + "JWKS URI를 입력해야 합니다.", + "msg.dev.clients.general.public_key.validation.missing_parsed_algorithms": + "JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}", + "msg.dev.clients.general.public_key.validation.private_key_jwt_requires_public_key": + "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다.", + "msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms": + "JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}", "msg.dev.clients.general.redirect.help": "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 설정 탭에서 수정 가능합니다.", "msg.dev.clients.general.save_error": "저장 실패: {{error}}", @@ -229,16 +388,14 @@ const Map koStrings = { "Includes PKCE, client_secret_basic, redirect URI validation tips.", "msg.dev.clients.help.subtitle": "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.", - "msg.dev.clients.load_error": "Error loading clients: {{error}}", - "msg.dev.clients.loading": "Loading apps...", + "msg.dev.clients.load_error": "앱 정보를 불러오지 못했습니다: {{error}}", + "msg.dev.clients.loading": "앱 정보를 불러오는 중...", "msg.dev.clients.registry.description": "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.", "msg.dev.clients.scopes.email": "이메일 주소 접근", "msg.dev.clients.scopes.openid": "OIDC 인증 필수 스코프", "msg.dev.clients.scopes.profile": "기본 프로필 정보 접근", - "msg.dev.clients.showing": "Showing {{shown}} of {{total}} apps", - "msg.dev.clients.status_update_error": "Failed to update client status", - "msg.dev.clients.status_updated": "앱이 {{status}}되었습니다.", + "msg.dev.clients.showing": "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다.", "msg.dev.dashboard.hero.body": "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.", "msg.dev.dashboard.hero.title_emphasis": " 하나의 화면", @@ -247,18 +404,43 @@ const Map koStrings = { "msg.dev.dashboard.notice.consent_audit": "Consent 회수는 감사 로그와 연계", "msg.dev.dashboard.notice.dev_scope": "RP 정책은 dev scope에서만 적용", "msg.dev.dashboard.notice.hydra_health": "Hydra Admin 상태 체크 준비", + "msg.dev.forbidden.default": "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.", + "msg.dev.forbidden.rp_admin": "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.", + "msg.dev.forbidden.tenant_admin": "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.", + "msg.dev.forbidden.title": "{{resource}} 접근 권한 없음", + "msg.dev.forbidden.user": "일반 사용자는 관리자 화면에 접근할 수 없습니다.", "msg.dev.logout_confirm": "로그아웃 하시겠습니까?", "msg.dev.sidebar.notice": "개발자 전용 콘솔입니다.", "msg.dev.sidebar.notice_detail": "연동 앱 등록 및 관리를 수행할 수 있습니다.", "msg.info.saved_success": "저장이 완료되었습니다.", + "msg.userfront.audit.browser": "브라우저: {{value}}", "msg.userfront.audit.date": "접속일자: {{value}}", "msg.userfront.audit.device": "접속환경: {{value}}", "msg.userfront.audit.end": "더 이상 항목이 없습니다.", + "msg.userfront.audit.filter.description": "활성화된 세션만 보려면 토글을 켜주세요.", + "msg.userfront.audit.filtered_empty": "활성 세션으로 필터링된 접속 이력이 없습니다.", "msg.userfront.audit.ip": "접속 IP: {{value}}", "msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.", "msg.userfront.audit.result": "인증결과: {{value}}", "msg.userfront.audit.session_id": "Session ID: {{value}}", "msg.userfront.audit.status": "현황: (준비중)", + "msg.userfront.consent.accept_error": "동의 처리에 실패했습니다: {{error}}", + "msg.userfront.consent.cancel.confirm": + "권한 동의를 취소하면 해당 서비스를 이용할 수 없습니다. 취소하시겠습니까?", + "msg.userfront.consent.cancel.error": "취소 처리 중 오류가 발생했습니다: {{error}}", + "msg.userfront.consent.client_id": "클라이언트 ID: {{id}}", + "msg.userfront.consent.client_unknown": "알 수 없는 앱", + "msg.userfront.consent.description": + "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\\\\n계속 진행하려면 동의 여부를 선택해 주세요.", + "msg.userfront.consent.load_error": "동의 정보를 불러오는데 실패했습니다: {{error}}", + "msg.userfront.consent.missing_redirect": "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다.", + "msg.userfront.consent.redirect_notice": "동의 후 자동으로 서비스로 이동합니다.", + "msg.userfront.consent.scope.email": "이메일 주소 (계정 식별 및 알림 용도)", + "msg.userfront.consent.scope.offline_access": "오프라인 접근 (로그인 유지)", + "msg.userfront.consent.scope.openid": "OpenID 인증 정보 (로그인 상태 확인)", + "msg.userfront.consent.scope.phone": "휴대폰 번호 (본인 인증 및 알림)", + "msg.userfront.consent.scope.profile": "기본 프로필 정보 (이름, 사용자 식별자)", + "msg.userfront.consent.scope_count": "총 {{count}}개", "msg.userfront.dashboard.activities.empty": "연동된 앱이 없습니다.", "msg.userfront.dashboard.activities.empty_detail": "앱을 연동하면 최근 활동과 상태가 표시됩니다.", @@ -266,9 +448,9 @@ const Map koStrings = { "msg.userfront.dashboard.approved_device": "승인 기기: {{device}}", "msg.userfront.dashboard.approved_ip": "승인 IP: {{ip}}", "msg.userfront.dashboard.approved_session.copy_click": - "{{label}}: {{id}}\n클릭하면 복사됩니다.", + "{{label}}: {{id}}\\\\n클릭하면 복사됩니다.", "msg.userfront.dashboard.approved_session.copy_tap": - "{{label}}: {{id}}\n탭하면 복사됩니다.", + "{{label}}: {{id}}\\\\n탭하면 복사됩니다.", "msg.userfront.dashboard.approved_session.none": "{{label}} 없음", "msg.userfront.dashboard.audit_empty": "최근 접속 이력이 없습니다.", "msg.userfront.dashboard.audit_load_error": "접속이력을 불러오지 못했습니다.", @@ -279,15 +461,28 @@ const Map koStrings = { "msg.userfront.dashboard.last_auth": "최근 인증: {{value}}", "msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.", "msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.", + "msg.userfront.dashboard.link_status": "연동 상태: {{status}}", "msg.userfront.dashboard.render_error": "대시보드 렌더링 오류: {{error}}", "msg.userfront.dashboard.revoke.confirm": - "{{app}} 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다.", + "{{app}} 앱과의 연동을 해지하시겠습니까?\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.", "msg.userfront.dashboard.revoke.error": "해지 실패: {{error}}", "msg.userfront.dashboard.revoke.success": "{{app}} 연동이 해지되었습니다.", "msg.userfront.dashboard.scopes.empty": "요청된 권한이 없습니다.", "msg.userfront.dashboard.session_id_copied": "세션 ID가 복사되었습니다.", + "msg.userfront.dashboard.sessions.browser": "브라우저: {{value}}", + "msg.userfront.dashboard.sessions.empty": "활성 세션이 없습니다.", + "msg.userfront.dashboard.sessions.empty_detail": + "같은 계정으로 로그인한 기기가 여기에 표시됩니다.", + "msg.userfront.dashboard.sessions.error": "세션 정보를 불러오지 못했습니다.", + "msg.userfront.dashboard.sessions.os": "OS: {{value}}", + "msg.userfront.dashboard.sessions.recent_app": "최근 접속 앱: {{app}}", + "msg.userfront.dashboard.sessions.revoke.confirm": + "{{target}} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다.", + "msg.userfront.dashboard.sessions.revoke.error": "세션 종료 실패: {{error}}", + "msg.userfront.dashboard.sessions.revoke.success": "세션이 종료되었습니다.", + "msg.userfront.dashboard.sessions.session_id": "세션 ID: {{id}}", "msg.userfront.dashboard.timeline.load_error": "접속이력을 불러오지 못했습니다.", - "msg.userfront.error.detail_contact": "msg.userfront.error.detail_contact", + "msg.userfront.error.detail_contact": "관리자에게 문의해 주세요.", "msg.userfront.error.detail_generic": "오류가 발생했습니다.", "msg.userfront.error.detail_request": "요청을 처리하는 중 문제가 발생했습니다.", "msg.userfront.error.id": "오류 ID: {{id}}", @@ -331,7 +526,7 @@ const Map koStrings = { "msg.userfront.greeting": "안녕하세요, {{name}}님", "msg.userfront.login.cookie_check_failed": "로그인 확인 실패: {{error}}", "msg.userfront.login.dry_send": "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", - "msg.userfront.login.link.approved": "링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.", + "msg.userfront.login.link.approved": "msg.userfront.login.link.approved", "msg.userfront.login.link.helper": "입력하신 정보로 로그인 링크를 전송합니다.", "msg.userfront.login.link.missing_login_id": "이메일 또는 휴대폰 번호를 입력해 주세요.", "msg.userfront.login.link.missing_phone": "휴대폰 번호를 입력해 주세요.", @@ -355,7 +550,7 @@ const Map koStrings = { "msg.userfront.login.qr_login_required": "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다", "msg.userfront.login.short_code.invalid": "문자 2개와 숫자 6자리를 입력해 주세요.", "msg.userfront.login.token_missing": "로그인 토큰을 확인할 수 없습니다.", - "msg.userfront.login.unregistered.body": "가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요.", + "msg.userfront.login.unregistered.body": "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요.", "msg.userfront.login.verification.approved": "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.", "msg.userfront.login.verification.approved_local": "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다", @@ -387,8 +582,8 @@ const Map koStrings = { "msg.userfront.profile.section.security": "비밀번호를 안전하게 관리합니다.", "msg.userfront.profile.update_failed": "수정 실패: {{error}}", "msg.userfront.profile.update_success": "정보가 수정되었습니다.", - "msg.userfront.qr.approve_error": "QR 승인 실패: {{error}}", - "msg.userfront.qr.approve_success": "QR 승인 완료! PC 화면에서 로그인이 진행됩니다.", + "msg.userfront.qr.approve_error": "msg.userfront.qr.approve_error", + "msg.userfront.qr.approve_success": "msg.userfront.qr.approve_success", "msg.userfront.qr.camera_error": "카메라 오류: {{error}}", "msg.userfront.qr.permission_error": "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.", "msg.userfront.qr.permission_required": "카메라 권한이 필요합니다.", @@ -415,11 +610,21 @@ const Map koStrings = { "msg.userfront.reset.success": "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.", "msg.userfront.sections.apps_subtitle": "현재 연결된 앱과 최근 인증 상태입니다.", "msg.userfront.sections.audit_subtitle": "Baron 로그인 기준의 최근 접근 기록입니다.", + "msg.userfront.sections.sessions_subtitle": "현재 로그인된 기기와 브라우저 세션입니다.", "msg.userfront.settings.disabled": "현재 계정 설정 화면은 준비 중입니다.", - "msg.userfront.signup.agreement.title": "서비스 이용을 위해\n약관에 동의해주세요", + "msg.userfront.signup.agreement.all_hint": + "필수 약관 2개를 모두 확인하고 동의하면 다음 단계로 진행할 수 있습니다.", + "msg.userfront.signup.agreement.description": + "계속 진행하려면 서비스 이용 조건과 개인정보 수집·이용 항목을 확인한 뒤 동의해주세요.", + "msg.userfront.signup.agreement.privacy_summary": + "개인정보 수집 항목, 이용 목적, 보관 기준을 안내합니다.", + "msg.userfront.signup.agreement.progress": + "필수 약관 {{total}}개 중 {{count}}개 동의 완료", + "msg.userfront.signup.agreement.title": "서비스 이용을 위해\\\\n약관에 동의해주세요", + "msg.userfront.signup.agreement.tos_summary": "서비스 이용 조건과 책임 범위를 확인할 수 있습니다.", "msg.userfront.signup.auth.affiliate_notice": "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.", - "msg.userfront.signup.auth.title": "본인 확인을 위해\n인증을 진행해주세요", + "msg.userfront.signup.auth.title": "본인 확인을 위해\\\\n인증을 진행해주세요", "msg.userfront.signup.email.code_mismatch": "인증코드가 일치하지 않습니다.", "msg.userfront.signup.email.duplicate": "이미 가입된 이메일입니다.", "msg.userfront.signup.email.invalid": "유효한 이메일 형식이 아닙니다.", @@ -439,7 +644,7 @@ const Map koStrings = { "msg.userfront.signup.password.rule.symbol": "특수문자", "msg.userfront.signup.password.rule.uppercase": "대문자", "msg.userfront.signup.password.symbol_required": "특수문자가 최소 1개 이상 포함되어야 합니다.", - "msg.userfront.signup.password.title": "마지막으로\n비밀번호를 설정해주세요", + "msg.userfront.signup.password.title": "마지막으로\\\\n비밀번호를 설정해주세요", "msg.userfront.signup.password.uppercase_required": "대문자가 최소 1개 이상 포함되어야 합니다.", "msg.userfront.signup.phone.code_mismatch": "인증코드가 일치하지 않습니다.", @@ -454,14 +659,14 @@ const Map koStrings = { "msg.userfront.signup.policy.summary": "보안 정책: {{rules}}", "msg.userfront.signup.policy.symbol": "특수문자", "msg.userfront.signup.policy.uppercase": "대문자", - "msg.userfront.signup.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", + "msg.userfront.signup.privacy_full": "개인정보 수집 및 이용 동의 전문...", "msg.userfront.signup.profile.affiliate_hint": "가족사 이메일 사용 시 자동으로 선택됩니다.", - "msg.userfront.signup.profile.title": "회원님의\n소속 정보를 알려주세요", + "msg.userfront.signup.profile.title": "회원님의\\\\n소속 정보를 알려주세요", "msg.userfront.signup.success.body": "성공적으로 가입되었습니다.", "msg.userfront.signup.success.title": "회원가입 완료", - "msg.userfront.signup.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.tos_full": "서비스 이용약관 전문...", + "non.existent.key": "존재하지 않는 키", + "test.key": "테스트", "ui.admin.api_keys.create.name_label": "서비스 또는 목적 식별 이름", "ui.admin.api_keys.create.name_placeholder": "예: Jenkins-CI, Grafana-Dashboard", @@ -515,7 +720,7 @@ const Map koStrings = { "ui.admin.audit.title": "감사 로그", "ui.admin.brand": "Baron 로그인", "ui.admin.dev_role_switcher": "🛠 DEV Role Switcher", - "ui.admin.groups.add_unit": "조직 추가", + "ui.admin.dev_role_switcher_real": "실제 역할 사용", "ui.admin.groups.create.description": "부서나 팀과 같은 새로운 조직 단위를 추가합니다.", "ui.admin.groups.create.title": "새 그룹 생성", "ui.admin.groups.detail.breadcrumb_org": "조직 관리 목록으로 돌아가기", @@ -530,7 +735,6 @@ const Map koStrings = { "ui.admin.groups.form.name_label": "그룹 이름", "ui.admin.groups.form.name_placeholder": "예: 개발팀, 인사팀", "ui.admin.groups.form.parent_label": "상위 조직", - "ui.admin.groups.form.parent_none": "없음 (최상위)", "ui.admin.groups.form.submit": "생성하기", "ui.admin.groups.form.unit_level_label": "조직 레벨", "ui.admin.groups.form.unit_level_placeholder": "예: 본부, 팀", @@ -540,11 +744,10 @@ const Map koStrings = { "ui.admin.groups.members.table.name": "이름", "ui.admin.groups.members.table.remove": "제거", "ui.admin.groups.table.actions": "ACTIONS", - "ui.admin.groups.table.created_at": "생성일", - "ui.admin.groups.table.level": "레벨", "ui.admin.groups.table.members": "MEMBERS", "ui.admin.groups.table.name": "NAME", "ui.admin.header.plane": "Admin Plane", + "ui.admin.header.subtitle": "관리 및 정책 운영", "ui.admin.nav.api_keys": "API 키", "ui.admin.nav.audit_logs": "감사 로그", "ui.admin.nav.auth_guard": "인증 가드", @@ -555,17 +758,27 @@ const Map koStrings = { "ui.admin.nav.tenants": "테넌트", "ui.admin.nav.user_groups": "유저 그룹", "ui.admin.nav.users": "사용자", + "ui.admin.org.download_template": "템플릿 다운로드", + "ui.admin.org.import_btn": "임포트", + "ui.admin.org.import_title": "조직도 대량 등록", + "ui.admin.org.start_import": "임포트 시작", "ui.admin.overview.kicker": "Global Overview", "ui.admin.overview.playbook.title": "Admin playbook", "ui.admin.overview.quick_links.add_tenant": "테넌트 추가", - "ui.admin.overview.quick_links.tenant_dashboard": "테넌트 대시보드", + "ui.admin.overview.quick_links.api_key_management": "API 키 관리", "ui.admin.overview.quick_links.title": "빠른 이동", + "ui.admin.overview.quick_links.user_management": "사용자 관리", "ui.admin.overview.quick_links.view_audit_logs": "감사 로그 보기", - "ui.admin.overview.title": "Tenant-independent control plane", + "ui.admin.overview.summary.audit_events_24h": "24시간 이벤트", + "ui.admin.overview.summary.oidc_clients": "OIDC 클라이언트", + "ui.admin.overview.summary.policy_gate": "정책 게이트", + "ui.admin.overview.summary.total_tenants": "전체 테넌트 수", + "ui.admin.overview.title": "통합 대시보드", + "ui.admin.profile.manageable_tenants": "관리 가능한 테넌트", "ui.admin.role.rp_admin": "RP ADMIN", "ui.admin.role.super_admin": "SUPER ADMIN", "ui.admin.role.tenant_admin": "TENANT ADMIN", - "ui.admin.role.tenant_member": "TENANT MEMBER", + "ui.admin.role.user": "TENANT MEMBER", "ui.admin.tenants.add": "테넌트 추가", "ui.admin.tenants.admins.add_button": "관리자 추가", "ui.admin.tenants.admins.already_admin": "이미 관리자", @@ -583,39 +796,58 @@ const Map koStrings = { "ui.admin.tenants.breadcrumb.section": "Tenants", "ui.admin.tenants.create.breadcrumb.action": "Create", "ui.admin.tenants.create.breadcrumb.section": "Tenants", - "ui.admin.tenants.create.form.description": "Description", + "ui.admin.tenants.create.form.description": "설명", "ui.admin.tenants.create.form.domains_label": "Allowed Domains (Comma separated)", "ui.admin.tenants.create.form.domains_placeholder": "example.com, example.kr", - "ui.admin.tenants.create.form.name": "Tenant name", - "ui.admin.tenants.create.form.parent": "상위 테넌트 (선택)", + "ui.admin.tenants.create.form.name": "테넌트 이름", + "ui.admin.tenants.create.form.name_placeholder": "테넌트 이름을 입력하세요", + "ui.admin.tenants.create.form.parent": "상위 테넌트", "ui.admin.tenants.create.form.slug": "Slug", "ui.admin.tenants.create.form.slug_placeholder": "tenant-slug", - "ui.admin.tenants.create.form.status": "Status", - "ui.admin.tenants.create.form.type": "테넌트 유형", + "ui.admin.tenants.create.form.status": "상태", + "ui.admin.tenants.create.form.type": "유형", "ui.admin.tenants.create.memo.title": "정책 메모", "ui.admin.tenants.create.profile.title": "Tenant Profile", "ui.admin.tenants.create.title": "테넌트 추가", "ui.admin.tenants.detail.breadcrumb_list": "테넌트 목록", "ui.admin.tenants.detail.header_subtitle": "테넌트 정보를 수정하거나 연동 설정을 관리합니다.", "ui.admin.tenants.detail.loading": "불러오는 중...", - "ui.admin.tenants.detail.tab_admins": "관리자 설정", "ui.admin.tenants.detail.tab_federation": "외부 연동", "ui.admin.tenants.detail.tab_organization": "조직 관리", + "ui.admin.tenants.detail.tab_permissions": "권한", "ui.admin.tenants.detail.tab_profile": "프로필", "ui.admin.tenants.detail.tab_schema": "사용자 스키마", "ui.admin.tenants.detail.title": "상세", "ui.admin.tenants.list.select_placeholder": "테넌트를 선택하세요", + "ui.admin.tenants.members.descendants": "하위 조직 멤버", + "ui.admin.tenants.members.direct": "소속 멤버", + "ui.admin.tenants.members.direct_label": "직속", + "ui.admin.tenants.members.list_title": "구성원 관리", "ui.admin.tenants.members.table.email": "EMAIL", "ui.admin.tenants.members.table.name": "NAME", "ui.admin.tenants.members.table.role": "ROLE", "ui.admin.tenants.members.table.status": "STATUS", - "ui.admin.tenants.members.title": "Tenant Members ({{count}})", + "ui.admin.tenants.members.title": "테넌트 구성원 ({{count}})", + "ui.admin.tenants.members.total": "전체", + "ui.admin.tenants.members.total_label": "전체", + "ui.admin.tenants.owners.add_button": "소유자 추가", + "ui.admin.tenants.owners.already_owner": "이미 소유자", + "ui.admin.tenants.owners.dialog_description": "이름 또는 이메일로 사용자를 검색하세요.", + "ui.admin.tenants.owners.dialog_title": "새 소유자 추가", + "ui.admin.tenants.owners.remove_title": "소유자 권한 회수", + "ui.admin.tenants.owners.table_actions": "액션", + "ui.admin.tenants.owners.table_email": "이메일", + "ui.admin.tenants.owners.table_name": "이름", + "ui.admin.tenants.owners.title": "테넌트 소유자", "ui.admin.tenants.profile.allowed_domains": "허용된 도메인 (콤마로 구분)", "ui.admin.tenants.profile.allowed_domains_help": "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.", "ui.admin.tenants.profile.approve_button": "테넌트 승인", "ui.admin.tenants.profile.description": "설명", + "ui.admin.tenants.profile.form.parent": "상위 테넌트 (선택)", + "ui.admin.tenants.profile.form.parent_help": + "가족사 테넌트나 하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.", "ui.admin.tenants.profile.name": "테넌트 이름", "ui.admin.tenants.profile.slug": "슬러그 (Slug)", "ui.admin.tenants.profile.status": "상태", @@ -623,32 +855,58 @@ const Map koStrings = { "ui.admin.tenants.profile.title": "테넌트 프로필", "ui.admin.tenants.profile.type": "테넌트 유형", "ui.admin.tenants.registry.title": "Tenant registry", - "ui.admin.tenants.schema.add_field": "Add Field", + "ui.admin.tenants.schema.add_field": "필드 추가", + "ui.admin.tenants.schema.field.admin_only": "관리자 전용", + "ui.admin.tenants.schema.field.is_login_id": "로그인 ID로 사용", "ui.admin.tenants.schema.field.key": "Field Key (ID)", "ui.admin.tenants.schema.field.key_placeholder": "e.g. employee_id", - "ui.admin.tenants.schema.field.label": "Display Label", - "ui.admin.tenants.schema.field.label_placeholder": "e.g. 사번", - "ui.admin.tenants.schema.field.type": "Type", + "ui.admin.tenants.schema.field.label": "표시 레이블", + "ui.admin.tenants.schema.field.label_placeholder": "예: 사번", + "ui.admin.tenants.schema.field.required": "필수 여부", + "ui.admin.tenants.schema.field.type": "타입", "ui.admin.tenants.schema.field.type_boolean": "Boolean", + "ui.admin.tenants.schema.field.type_date": "Date", + "ui.admin.tenants.schema.field.type_datetime": "일시 (DateTime)", + "ui.admin.tenants.schema.field.type_float": "실수 (Float)", "ui.admin.tenants.schema.field.type_number": "Number", - "ui.admin.tenants.schema.field.type_text": "Text", - "ui.admin.tenants.schema.save": "Save Schema Changes", + "ui.admin.tenants.schema.field.type_text": "텍스트", + "ui.admin.tenants.schema.field.unsigned": "음수 불가", + "ui.admin.tenants.schema.field.validation_placeholder": "정규표현식 (선택 사항)", + "ui.admin.tenants.schema.save": "스키마 저장", "ui.admin.tenants.schema.title": "User Schema Extension", "ui.admin.tenants.sub.add": "하위 테넌트 추가", + "ui.admin.tenants.sub.add_dialog_desc": "하위 테넌트로 추가할 테넌트를 선택하세요.", + "ui.admin.tenants.sub.add_dialog_title": "하위 테넌트 추가", + "ui.admin.tenants.sub.add_existing": "기존 테넌트 추가", "ui.admin.tenants.sub.manage": "관리", + "ui.admin.tenants.sub.no_candidates": "추가 가능한 테넌트가 없습니다.", + "ui.admin.tenants.sub.search_placeholder": "검색...", "ui.admin.tenants.sub.table.action": "ACTION", "ui.admin.tenants.sub.table.name": "NAME", "ui.admin.tenants.sub.table.slug": "SLUG", "ui.admin.tenants.sub.table.status": "STATUS", - "ui.admin.tenants.sub.title": "Sub-tenants ({{count}})", + "ui.admin.tenants.sub.title": "하위 테넌트 ({{count}})", + "ui.admin.tenants.sub.tree_search_placeholder": "트리에서 검색...", "ui.admin.tenants.table.actions": "ACTIONS", + "ui.admin.tenants.table.members": "멤버수", "ui.admin.tenants.table.name": "NAME", "ui.admin.tenants.table.slug": "SLUG", "ui.admin.tenants.table.status": "STATUS", - "ui.admin.tenants.table.type": "TYPE", + "ui.admin.tenants.table.type": "유형", "ui.admin.tenants.table.updated": "UPDATED", "ui.admin.tenants.title": "테넌트 목록", "ui.admin.title": "Admin Control", + "ui.admin.users.bulk.acknowledge_warning": "경고를 확인했으며 계속 진행합니다.", + "ui.admin.users.bulk.do_move": "이동 실행", + "ui.admin.users.bulk.download_template": "템플릿 받기", + "ui.admin.users.bulk.move_group": "테넌트 일괄 이동", + "ui.admin.users.bulk.move_title": "사용자 일괄 이동", + "ui.admin.users.bulk.no_department": "부서 없음", + "ui.admin.users.bulk.schema_warning": "스키마 호환성 경고", + "ui.admin.users.bulk.select_group": "대상 테넌트 선택", + "ui.admin.users.bulk.selected_count": "{{count}}명 선택됨", + "ui.admin.users.bulk.start_upload": "업로드 시작", + "ui.admin.users.bulk.title": "일괄 작업", "ui.admin.users.create.account.title": "계정 정보", "ui.admin.users.create.back": "목록으로 돌아가기", "ui.admin.users.create.breadcrumb.new": "New", @@ -659,8 +917,11 @@ const Map koStrings = { "ui.admin.users.create.form.department_placeholder": "개발팀", "ui.admin.users.create.form.email": "이메일", "ui.admin.users.create.form.email_placeholder": "user@example.com", + "ui.admin.users.create.form.is_login_id": "로그인 ID", "ui.admin.users.create.form.job_title": "직무", "ui.admin.users.create.form.job_title_placeholder": "프론트엔드 개발", + "ui.admin.users.create.form.login_id": "로그인 ID (선택)", + "ui.admin.users.create.form.login_id_placeholder": "사번 또는 아이디", "ui.admin.users.create.form.name": "이름", "ui.admin.users.create.form.name_placeholder": "홍길동", "ui.admin.users.create.form.password": "비밀번호", @@ -669,59 +930,454 @@ const Map koStrings = { "ui.admin.users.create.form.phone_placeholder": "010-1234-5678", "ui.admin.users.create.form.position": "직급", "ui.admin.users.create.form.position_placeholder": "수석/책임/선임", - "ui.admin.users.create.form.role": "역할 (Role)", - "ui.admin.users.create.form.tenant": "테넌트 (Tenant)", - "ui.admin.users.create.form.tenant_global": "시스템 전역 (소속 없음)", + "ui.admin.users.create.form.role": "역할", + "ui.admin.users.create.form.tenant": "테넌트", + "ui.admin.users.create.form.tenant_global": "시스템 전역", "ui.admin.users.create.go_list": "목록으로 이동", "ui.admin.users.create.password_generated.title": "초기 비밀번호 생성 완료", "ui.admin.users.create.submit": "사용자 생성", "ui.admin.users.create.title": "사용자 추가", "ui.admin.users.detail.back": "목록으로 돌아가기", "ui.admin.users.detail.breadcrumb.section": "Users", - "ui.admin.users.detail.custom_fields.title": "테넌트 확장 정보 (Custom Fields)", + "ui.admin.users.detail.contact_title": "ui.admin.users.detail.contact_title", + "ui.admin.users.detail.created_at": "가입일", + "ui.admin.users.detail.custom_fields.multi_title": "테넌트별 프로필 관리", + "ui.admin.users.detail.delete": "사용자 삭제", "ui.admin.users.detail.edit_title": "정보 수정", + "ui.admin.users.detail.form.- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache": + "ui.admin.users.detail.form.- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache", "ui.admin.users.detail.form.department": "부서", "ui.admin.users.detail.form.department_placeholder": "개발팀", + "ui.admin.users.detail.form.email": "이메일", + "ui.admin.users.detail.form.is_login_id": "로그인 ID", "ui.admin.users.detail.form.job_title": "직무", - "ui.admin.users.detail.form.job_title_placeholder": "프론트엔드 개발", + "ui.admin.users.detail.form.job_title_placeholder": + "ui.admin.users.detail.form.job_title_placeholder", + "ui.admin.users.detail.form.login_id": "로그인 ID", + "ui.admin.users.detail.form.login_id_placeholder": "사번 또는 아이디", + "ui.admin.users.detail.form.msg.admin.users.detail.history_desc": + "ui.admin.users.detail.form.msg.admin.users.detail.history_desc", + "ui.admin.users.detail.form.msg.admin.users.detail.no_history": + "ui.admin.users.detail.form.msg.admin.users.detail.no_history", + "ui.admin.users.detail.form.msg.admin.users.detail.no_tenants": + "ui.admin.users.detail.form.msg.admin.users.detail.no_tenants", + "ui.admin.users.detail.form.msg.admin.users.detail.reset_auto_desc": + "ui.admin.users.detail.form.msg.admin.users.detail.reset_auto_desc", + "ui.admin.users.detail.form.msg.admin.users.detail.security_desc": + "ui.admin.users.detail.form.msg.admin.users.detail.security_desc", + "ui.admin.users.detail.form.msg.admin.users.detail.tenant_slug_help": + "ui.admin.users.detail.form.msg.admin.users.detail.tenant_slug_help", + "ui.admin.users.detail.form.msg.admin.users.detail.tenants_desc": + "ui.admin.users.detail.form.msg.admin.users.detail.tenants_desc", + "ui.admin.users.detail.form.msg.common.copied": + "ui.admin.users.detail.form.msg.common.copied", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.allowed_algorithms_tooltip": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.allowed_algorithms_tooltip", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithm_badge": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithm_badge", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithm_reason": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithm_reason", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithms_help": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithms_help", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithms_title": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithms_title", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.parsed_keys_empty": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.parsed_keys_empty", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.parsed_keys_help": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.parsed_keys_help", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_empty": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_empty", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_help": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_help", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_refresh_failed": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_refresh_failed", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_refreshed": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_refreshed", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoke_confirm": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoke_confirm", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoke_failed": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoke_failed", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoked": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoked", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", "ui.admin.users.detail.form.name": "이름", "ui.admin.users.detail.form.name_placeholder": "홍길동", "ui.admin.users.detail.form.phone": "전화번호", "ui.admin.users.detail.form.phone_placeholder": "010-1234-5678", "ui.admin.users.detail.form.position": "직급", - "ui.admin.users.detail.form.position_placeholder": "수석/책임/선임", - "ui.admin.users.detail.form.role": "역할 (Role)", + "ui.admin.users.detail.form.position_placeholder": + "ui.admin.users.detail.form.position_placeholder", + "ui.admin.users.detail.form.role": "역할", + "ui.admin.users.detail.form.role_rp_admin": "RP 관리자", + "ui.admin.users.detail.form.role_super_admin": "시스템 전역 관리자", + "ui.admin.users.detail.form.role_tenant_admin": "테넌트 관리자", + "ui.admin.users.detail.form.role_user": "사용자", "ui.admin.users.detail.form.status": "상태", - "ui.admin.users.detail.form.tenant": "테넌트 (Tenant)", - "ui.admin.users.detail.form.tenant_global": "시스템 전역 (소속 없음)", + "ui.admin.users.detail.form.status_active": + "ui.admin.users.detail.form.status_active", + "ui.admin.users.detail.form.status_inactive": + "ui.admin.users.detail.form.status_inactive", + "ui.admin.users.detail.form.tenant": "대표 소속 테넌트", + "ui.admin.users.detail.form.tenant_global": "시스템 전역", + "ui.admin.users.detail.form.tenant_slug": "대표 소속 (Tenant Slug)", + "ui.admin.users.detail.form.ui.admin.users.create.form.is_login_id": + "ui.admin.users.detail.form.ui.admin.users.create.form.is_login_id", + "ui.admin.users.detail.form.ui.admin.users.detail.form.email": + "ui.admin.users.detail.form.ui.admin.users.detail.form.email", + "ui.admin.users.detail.form.ui.admin.users.detail.form.is_login_id": + "ui.admin.users.detail.form.ui.admin.users.detail.form.is_login_id", + "ui.admin.users.detail.form.ui.admin.users.detail.form.role_rp_admin": + "ui.admin.users.detail.form.ui.admin.users.detail.form.role_rp_admin", + "ui.admin.users.detail.form.ui.admin.users.detail.form.tenant_slug": + "ui.admin.users.detail.form.ui.admin.users.detail.form.tenant_slug", + "ui.admin.users.detail.form.ui.admin.users.detail.generate_button": + "ui.admin.users.detail.form.ui.admin.users.detail.generate_button", + "ui.admin.users.detail.form.ui.admin.users.detail.history_title": + "ui.admin.users.detail.form.ui.admin.users.detail.history_title", + "ui.admin.users.detail.form.ui.admin.users.detail.manual_confirm": + "ui.admin.users.detail.form.ui.admin.users.detail.manual_confirm", + "ui.admin.users.detail.form.ui.admin.users.detail.manual_password": + "ui.admin.users.detail.form.ui.admin.users.detail.manual_password", + "ui.admin.users.detail.form.ui.admin.users.detail.password_done": + "ui.admin.users.detail.form.ui.admin.users.detail.password_done", + "ui.admin.users.detail.form.ui.admin.users.detail.reset_auto": + "ui.admin.users.detail.form.ui.admin.users.detail.reset_auto", + "ui.admin.users.detail.form.ui.admin.users.detail.reset_execute": + "ui.admin.users.detail.form.ui.admin.users.detail.reset_execute", + "ui.admin.users.detail.form.ui.admin.users.detail.reset_manual": + "ui.admin.users.detail.form.ui.admin.users.detail.reset_manual", + "ui.admin.users.detail.form.ui.admin.users.detail.save_tenants": + "ui.admin.users.detail.form.ui.admin.users.detail.save_tenants", + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.info": + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.info", + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.security": + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.security", + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.tenants": + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.tenants", + "ui.admin.users.detail.form.ui.admin.users.detail.updated_at": + "ui.admin.users.detail.form.ui.admin.users.detail.updated_at", + "ui.admin.users.detail.form.ui.common.generate": + "ui.admin.users.detail.form.ui.common.generate", + "ui.admin.users.detail.form.ui.common.status.blocked": + "ui.admin.users.detail.form.ui.common.status.blocked", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.allowed_algorithms_info": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.allowed_algorithms_info", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_client_secret_basic": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_client_secret_basic", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_none": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_none", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_private_key_jwt": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_private_key_jwt", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.cached_at": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.cached_at", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.error": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.error", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.expires_at": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.expires_at", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.failures": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.failures", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.kids": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.kids", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.last_checked_at": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.last_checked_at", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.last_success": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.last_success", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.parsed_key_n": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.parsed_key_n", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.parsed_keys": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.parsed_keys", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.status": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.status", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.title": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.title", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.uri": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.uri", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.guide_toggle": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.guide_toggle", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.headless_disabled": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.headless_disabled", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.headless_enabled": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.headless_enabled", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.jwks_inline": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.jwks_inline", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.jwks_inline_placeholder": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.jwks_inline_placeholder", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.request_object_alg": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.request_object_alg", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.request_object_alg_placeholder": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.request_object_alg_placeholder", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.revoke_cache": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.revoke_cache", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.source": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.source", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.source_uri": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.source_uri", + "ui.admin.users.detail.form.ui.dev.clients.general.security.trusted_rp_enable": + "ui.admin.users.detail.form.ui.dev.clients.general.security.trusted_rp_enable", + "ui.admin.users.detail.form.ui.dev.clients.general.security.trusted_rp_enable_help": + "ui.admin.users.detail.form.ui.dev.clients.general.security.trusted_rp_enable_help", + "ui.admin.users.detail.form.ui.dev.clients.help.docs_body": + "ui.admin.users.detail.form.ui.dev.clients.help.docs_body", + "ui.admin.users.detail.form.ui.dev.clients.help.subtitle": + "ui.admin.users.detail.form.ui.dev.clients.help.subtitle", + "ui.admin.users.detail.form.ui.dev.clients.registry.description": + "ui.admin.users.detail.form.ui.dev.clients.registry.description", + "ui.admin.users.detail.form.ui.dev.clients.scopes.email": + "ui.admin.users.detail.form.ui.dev.clients.scopes.email", + "ui.admin.users.detail.form.ui.dev.clients.scopes.openid": + "ui.admin.users.detail.form.ui.dev.clients.scopes.openid", + "ui.admin.users.detail.form.ui.dev.clients.scopes.profile": + "ui.admin.users.detail.form.ui.dev.clients.scopes.profile", + "ui.admin.users.detail.form.ui.dev.session.refresh": + "ui.admin.users.detail.form.ui.dev.session.refresh", + "ui.admin.users.detail.form.ui.dev.session.refreshing": + "ui.admin.users.detail.form.ui.dev.session.refreshing", + "ui.admin.users.detail.generate_button": "랜덤 비밀번호 생성", + "ui.admin.users.detail.generate_password": "자동 생성", + "ui.admin.users.detail.go_list": "목록으로 이동", + "ui.admin.users.detail.history_title": "서비스 이용 내역", + "ui.admin.users.detail.manual_confirm": "비밀번호 확인", + "ui.admin.users.detail.manual_password": "새 비밀번호", + "ui.admin.users.detail.password_done": "성공적으로 초기화됨", + "ui.admin.users.detail.password_mode_generated": "자동 생성", + "ui.admin.users.detail.password_mode_manual": "수동 입력", + "ui.admin.users.detail.password_result_title": "Reset Password", + "ui.admin.users.detail.password_title": "비밀번호 관리", + "ui.admin.users.detail.reset_auto": "자동 생성", + "ui.admin.users.detail.reset_execute": "재설정 완료", + "ui.admin.users.detail.reset_manual": "직접 입력", + "ui.admin.users.detail.reset_password": "초기화 도구", + "ui.admin.users.detail.reset_password_apply": "비밀번호 적용", + "ui.admin.users.detail.reset_password_label": "비밀번호 초기화", + "ui.admin.users.detail.save": "저장하기", + "ui.admin.users.detail.save_tenants": "모든 테넌트 프로필 저장", "ui.admin.users.detail.security.password": "비밀번호 변경", "ui.admin.users.detail.security.password_placeholder": "변경할 경우에만 입력", "ui.admin.users.detail.security.title": "보안 설정", + "ui.admin.users.detail.status_title": "ui.admin.users.detail.status_title", + "ui.admin.users.detail.tabs.info": "기본 정보", + "ui.admin.users.detail.tabs.security": "보안 & 활동", + "ui.admin.users.detail.tabs.tenants": "테넌트 프로필", + "ui.admin.users.detail.tenants_section.additional": "추가 소속/관리 테넌트", + "ui.admin.users.detail.tenants_section.primary": "대표 소속 테넌트", + "ui.admin.users.detail.tenants_section.title": "소속 및 조직 정보", "ui.admin.users.detail.title": "사용자 상세", + "ui.admin.users.detail.toggle_password_visibility": "비밀번호 표시 전환", + "ui.admin.users.detail.updated_at": "최근 수정", "ui.admin.users.list.add": "사용자 추가", "ui.admin.users.list.breadcrumb.list": "List", "ui.admin.users.list.breadcrumb.section": "Users", - "ui.admin.users.list.delete_aria": "사용자 삭제: {{name}}", - "ui.admin.users.list.edit_aria": "사용자 수정: {{name}}", - "ui.admin.users.list.registry.title": "User Registry", + "ui.admin.users.list.bulk_import": "일괄 임포트", + "ui.admin.users.list.columns.title": "컬럼 설정", + "ui.admin.users.list.empty": "검색 결과가 없습니다.", + "ui.admin.users.list.fetch_error": "사용자 목록 조회에 실패했습니다.", + "ui.admin.users.list.filter.tenant": "테넌트 필터", + "ui.admin.users.list.registry.count": "총 {{count}}명의 사용자가 등록되어 있습니다.", + "ui.admin.users.list.registry.title": "사용자 레지스트리", "ui.admin.users.list.search_placeholder": "이름 또는 이메일 검색...", + "ui.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.", "ui.admin.users.list.table.actions": "ACTIONS", "ui.admin.users.list.table.created": "CREATED", + "ui.admin.users.list.table.login_id": "LOGIN ID", + "ui.admin.users.list.table.msg.admin.users.detail.history_desc": + "ui.admin.users.list.table.msg.admin.users.detail.history_desc", + "ui.admin.users.list.table.msg.admin.users.detail.no_history": + "ui.admin.users.list.table.msg.admin.users.detail.no_history", + "ui.admin.users.list.table.msg.admin.users.detail.no_tenants": + "ui.admin.users.list.table.msg.admin.users.detail.no_tenants", + "ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc": + "ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc", + "ui.admin.users.list.table.msg.admin.users.detail.security_desc": + "ui.admin.users.list.table.msg.admin.users.detail.security_desc", + "ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help": + "ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help", + "ui.admin.users.list.table.msg.admin.users.detail.tenants_desc": + "ui.admin.users.list.table.msg.admin.users.detail.tenants_desc", + "ui.admin.users.list.table.msg.common.copied": + "ui.admin.users.list.table.msg.common.copied", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", "ui.admin.users.list.table.name_email": "NAME / EMAIL", - "ui.admin.users.list.table.position_job": "POSITION / JOB", "ui.admin.users.list.table.role": "ROLE", "ui.admin.users.list.table.status": "STATUS", "ui.admin.users.list.table.tenant_dept": "TENANT / DEPT", - "ui.admin.users.list.tenant_slug": "Slug: {{slug}}", + "ui.admin.users.list.table.ui.admin.users.create.form.is_login_id": + "ui.admin.users.list.table.ui.admin.users.create.form.is_login_id", + "ui.admin.users.list.table.ui.admin.users.detail.form.email": + "ui.admin.users.list.table.ui.admin.users.detail.form.email", + "ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id": + "ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id", + "ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin": + "ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin", + "ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug": + "ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug", + "ui.admin.users.list.table.ui.admin.users.detail.generate_button": + "ui.admin.users.list.table.ui.admin.users.detail.generate_button", + "ui.admin.users.list.table.ui.admin.users.detail.history_title": + "ui.admin.users.list.table.ui.admin.users.detail.history_title", + "ui.admin.users.list.table.ui.admin.users.detail.manual_confirm": + "ui.admin.users.list.table.ui.admin.users.detail.manual_confirm", + "ui.admin.users.list.table.ui.admin.users.detail.manual_password": + "ui.admin.users.list.table.ui.admin.users.detail.manual_password", + "ui.admin.users.list.table.ui.admin.users.detail.password_done": + "ui.admin.users.list.table.ui.admin.users.detail.password_done", + "ui.admin.users.list.table.ui.admin.users.detail.reset_auto": + "ui.admin.users.list.table.ui.admin.users.detail.reset_auto", + "ui.admin.users.list.table.ui.admin.users.detail.reset_execute": + "ui.admin.users.list.table.ui.admin.users.detail.reset_execute", + "ui.admin.users.list.table.ui.admin.users.detail.reset_manual": + "ui.admin.users.list.table.ui.admin.users.detail.reset_manual", + "ui.admin.users.list.table.ui.admin.users.detail.save_tenants": + "ui.admin.users.list.table.ui.admin.users.detail.save_tenants", + "ui.admin.users.list.table.ui.admin.users.detail.tabs.info": + "ui.admin.users.list.table.ui.admin.users.detail.tabs.info", + "ui.admin.users.list.table.ui.admin.users.detail.tabs.security": + "ui.admin.users.list.table.ui.admin.users.detail.tabs.security", + "ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants": + "ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants", + "ui.admin.users.list.table.ui.admin.users.detail.updated_at": + "ui.admin.users.list.table.ui.admin.users.detail.updated_at", + "ui.admin.users.list.table.ui.common.generate": + "ui.admin.users.list.table.ui.common.generate", + "ui.admin.users.list.table.ui.common.status.blocked": + "ui.admin.users.list.table.ui.common.status.blocked", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.source": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.source", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri", + "ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable": + "ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable", + "ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help": + "ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help", + "ui.admin.users.list.table.ui.dev.clients.help.docs_body": + "ui.admin.users.list.table.ui.dev.clients.help.docs_body", + "ui.admin.users.list.table.ui.dev.clients.help.subtitle": + "ui.admin.users.list.table.ui.dev.clients.help.subtitle", + "ui.admin.users.list.table.ui.dev.clients.registry.description": + "ui.admin.users.list.table.ui.dev.clients.registry.description", + "ui.admin.users.list.table.ui.dev.clients.scopes.email": + "ui.admin.users.list.table.ui.dev.clients.scopes.email", + "ui.admin.users.list.table.ui.dev.clients.scopes.openid": + "ui.admin.users.list.table.ui.dev.clients.scopes.openid", + "ui.admin.users.list.table.ui.dev.clients.scopes.profile": + "ui.admin.users.list.table.ui.dev.clients.scopes.profile", + "ui.admin.users.list.table.ui.dev.session.refresh": + "ui.admin.users.list.table.ui.dev.session.refresh", + "ui.admin.users.list.table.ui.dev.session.refreshing": + "ui.admin.users.list.table.ui.dev.session.refreshing", "ui.admin.users.list.title": "사용자 관리", + "ui.admin.users.table.email": "이메일", + "ui.admin.users.table.name": "이름", + "ui.admin.users.table.role": "역할", "ui.common.add": "추가", "ui.common.admin_only": "관리자 전용", + "ui.common.all": "전체", "ui.common.assign": "할당", "ui.common.back": "돌아가기", + "ui.common.back_to_login": "로그인으로 돌아가기", "ui.common.badge.admin_only": "Admin only", "ui.common.badge.command_only": "Command only", "ui.common.badge.system": "System", "ui.common.cancel": "취소", + "ui.common.change_file": "파일 변경", + "ui.common.clear_search": "검색 초기화", "ui.common.close": "닫기", "ui.common.collapse": "접기", "ui.common.confirm": "확인", @@ -730,42 +1386,60 @@ const Map koStrings = { "ui.common.delete": "삭제", "ui.common.details": "상세정보", "ui.common.edit": "편집", + "ui.common.enabled": "사용", + "ui.common.export": "내보내기", + "ui.common.fail": "실패", + "ui.common.generate": "ui.common.generate", + "ui.common.go_home": "홈으로", "ui.common.hyphen": "-", "ui.common.language": "언어", "ui.common.language_en": "English", "ui.common.language_ko": "한국어", + "ui.common.manage": "관리", "ui.common.na": "N/A", "ui.common.never": "Never", - "ui.common.next": "Next", + "ui.common.next": "다음", "ui.common.none": "없음", "ui.common.page_of": "Page {{page}} of {{total}}", "ui.common.prev": "이전", - "ui.common.previous": "Previous", + "ui.common.previous": "이전", "ui.common.qr": "QR", "ui.common.read_only": "읽기 전용", "ui.common.refresh": "새로고침", - "ui.common.requesting": "요청 중...", + "ui.common.remove": "제외", "ui.common.resend": "재발송", + "ui.common.reset": "초기화", "ui.common.retry": "다시 시도", - "ui.common.role.admin": "Admin", - "ui.common.role.user": "User", "ui.common.save": "저장", "ui.common.search": "검색", - "ui.common.select": "사용자 선택", - "ui.common.select_placeholder": "사용자를 선택하세요", + "ui.common.select": "선택", + "ui.common.select_file": "파일 선택", + "ui.common.select_placeholder": "선택하세요", "ui.common.show_more": "+ 더보기", - "ui.common.status.active": "Active", - "ui.common.status.blocked": "Blocked", + "ui.common.status.active": "활성", + "ui.common.status.blocked": "ui.common.status.blocked", "ui.common.status.failure": "실패", - "ui.common.status.inactive": "Inactive", + "ui.common.status.inactive": "비활성", "ui.common.status.ok": "정상", "ui.common.status.pending": "준비 중", "ui.common.status.success": "성공", - "ui.common.theme_dark": "Dark", - "ui.common.theme_light": "Light", + "ui.common.success": "성공", + "ui.common.theme_dark": "다크", + "ui.common.theme_light": "라이트", "ui.common.theme_toggle": "테마 전환", "ui.common.unknown": "Unknown", "ui.common.view": "보기", + "ui.dev.audit.filter.action": "액션으로 필터 (예: ROTATE_SECRET)", + "ui.dev.audit.filter.client_id": "Client ID로 필터", + "ui.dev.audit.filter.status_all": "모든 상태", + "ui.dev.audit.load_more": "더 보기", + "ui.dev.audit.registry.title": "Audit registry", + "ui.dev.audit.table.action": "액션", + "ui.dev.audit.table.actor": "수행자", + "ui.dev.audit.table.status": "상태", + "ui.dev.audit.table.target": "대상", + "ui.dev.audit.table.time": "시간", + "ui.dev.audit.title": "감사 로그", "ui.dev.brand": "Baron 로그인", "ui.dev.clients.badge.admin_session": "관리자 세션", "ui.dev.clients.badge.tenant_selected": "테넌트: 선택됨", @@ -773,34 +1447,33 @@ const Map koStrings = { "ui.dev.clients.consents.breadcrumb.current": "User Consent Grants", "ui.dev.clients.consents.breadcrumb.home": "Home", "ui.dev.clients.consents.export_csv": "Export CSV", - "ui.dev.clients.consents.filters.advanced": "Advanced Filters", + "ui.dev.clients.consents.filters.advanced": "고급 필터", "ui.dev.clients.consents.revoke": "Revoke", + "ui.dev.clients.consents.revoked_at": "철회일: ", + "ui.dev.clients.consents.scope_label": "권한:", "ui.dev.clients.consents.search_placeholder": "사용자 ID, 이름, 이메일로 검색", - "ui.dev.clients.consents.stats.active_grants": "Active Grants", - "ui.dev.clients.consents.stats.avg_scopes": "Avg. Scopes per User", - "ui.dev.clients.consents.stats.total_scopes": "Total Scopes Issued", - "ui.dev.clients.consents.status_all": "All Statuses", + "ui.dev.clients.consents.stats.active_grants": "활성 동의 건수", + "ui.dev.clients.consents.stats.avg_scopes": "사용자당 평균 스코프", + "ui.dev.clients.consents.stats.total_scopes": "총 발급 스코프 수", + "ui.dev.clients.consents.status_all": "ui.dev.clients.consents.status_all", "ui.dev.clients.consents.status_label": "Status:", "ui.dev.clients.consents.status_revoked": "Revoked", "ui.dev.clients.consents.subject": "Subject", - "ui.dev.clients.consents.table.action": "Action", - "ui.dev.clients.consents.table.first_granted": "First Granted", - "ui.dev.clients.consents.table.last_auth": "Last Authenticated", - "ui.dev.clients.consents.table.scopes": "Granted Scopes", - "ui.dev.clients.consents.table.status": "Status", - "ui.dev.clients.consents.table.tenant": "Tenant", - "ui.dev.clients.consents.table.user": "User", + "ui.dev.clients.consents.table.action": "동작", + "ui.dev.clients.consents.table.first_granted": "최초 동의 시각", + "ui.dev.clients.consents.table.last_auth": "마지막 인증 시각", + "ui.dev.clients.consents.table.scopes": "승인된 스코프", + "ui.dev.clients.consents.table.status": "상태", + "ui.dev.clients.consents.table.tenant": "테넌트", + "ui.dev.clients.consents.table.user": "사용자", "ui.dev.clients.consents.title": "User Consent Grants", - "ui.dev.clients.copy_client_id": "Copy client id", - "ui.dev.clients.details.breadcrumb.current": "연동 앱 상세", - "ui.dev.clients.details.breadcrumb.section": "연동 앱", - "ui.dev.clients.details.credentials.client_id": "Client ID", - "ui.dev.clients.details.credentials.client_secret": "Client Secret", + "ui.dev.clients.details.credentials.client_id": "클라이언트 ID", + "ui.dev.clients.details.credentials.client_secret": "클라이언트 시크릿", "ui.dev.clients.details.credentials.title": "앱 자격 증명", "ui.dev.clients.details.endpoints.read_only": "읽기 전용", "ui.dev.clients.details.endpoints.title": "OIDC 엔드포인트", "ui.dev.clients.details.redirect.callback_label": "인증 콜백 URL", - "ui.dev.clients.details.redirect.label": "Redirect URIs", + "ui.dev.clients.details.redirect.label": "리디렉션 URI", "ui.dev.clients.details.redirect.placeholder": "https://your-app.com/callback, http://localhost:3000/auth/callback", "ui.dev.clients.details.redirect.save": "Redirect URIs 저장", @@ -810,43 +1483,128 @@ const Map koStrings = { "ui.dev.clients.details.secret.show": "비밀키 보기", "ui.dev.clients.details.security.title": "보안 메모", "ui.dev.clients.details.tab.connection": "연동 설정", - "ui.dev.clients.details.tab.consents": "Consent & Users", - "ui.dev.clients.details.tab.settings": "Settings", + "ui.dev.clients.details.tab.consents": "동의 및 사용자", + "ui.dev.clients.details.tab.settings": "설정", "ui.dev.clients.federation.add_btn": "Add Provider", "ui.dev.clients.federation.add_title": "Add Identity Provider", "ui.dev.clients.federation.title": "Identity Federation", - "ui.dev.clients.general.breadcrumb.section": "Applications", + "ui.dev.clients.filter.status_all": "모든 상태", + "ui.dev.clients.filter.type_all": "모든 유형", + "ui.dev.clients.filter.type_label": "유형:", "ui.dev.clients.general.create": "앱 생성", "ui.dev.clients.general.display_new": "연동 앱 추가", - "ui.dev.clients.general.footer.client_id": "Client ID", - "ui.dev.clients.general.footer.created_on": "Created On", - "ui.dev.clients.general.identity.description": "Description", + "ui.dev.clients.general.identity.description": "설명", "ui.dev.clients.general.identity.description_placeholder": "앱에 대한 간단한 설명을 입력하세요.", - "ui.dev.clients.general.identity.logo": "App Logo URL", + "ui.dev.clients.general.identity.logo": "앱 로고 URL", "ui.dev.clients.general.identity.logo_placeholder": "https://example.com/logo.png", - "ui.dev.clients.general.identity.logo_preview": "Logo Preview", + "ui.dev.clients.general.identity.logo_preview": "로고 미리보기", "ui.dev.clients.general.identity.name": "앱 이름", - "ui.dev.clients.general.identity.name_placeholder": "My Awesome Application", - "ui.dev.clients.general.identity.title": "Application Identity", - "ui.dev.clients.general.redirect.label": "Redirect URIs", + "ui.dev.clients.general.identity.name_placeholder": "예: 멋진 애플리케이션", + "ui.dev.clients.general.identity.title": "애플리케이션 정보", + "ui.dev.clients.general.public_key.allowed_algorithms_info": + "Headless Login 허용 알고리즘 정보", + "ui.dev.clients.general.public_key.auth_method": + "ui.dev.clients.general.public_key.auth_method", + "ui.dev.clients.general.public_key.auth_method_client_secret_basic": + "ui.dev.clients.general.public_key.auth_method_client_secret_basic", + "ui.dev.clients.general.public_key.auth_method_none": + "ui.dev.clients.general.public_key.auth_method_none", + "ui.dev.clients.general.public_key.auth_method_private_key_jwt": + "ui.dev.clients.general.public_key.auth_method_private_key_jwt", + "ui.dev.clients.general.public_key.cache.cached_at": "Cached At", + "ui.dev.clients.general.public_key.cache.error": "Last Error", + "ui.dev.clients.general.public_key.cache.expires_at": "Expires At", + "ui.dev.clients.general.public_key.cache.failures": "Consecutive Failures", + "ui.dev.clients.general.public_key.cache.kids": "Cached KIDs", + "ui.dev.clients.general.public_key.cache.last_checked_at": "Last Checked", + "ui.dev.clients.general.public_key.cache.last_success": + "Last Successful Verification", + "ui.dev.clients.general.public_key.cache.parsed_key_n": "N", + "ui.dev.clients.general.public_key.cache.parsed_keys": "Parsed Keys", + "ui.dev.clients.general.public_key.cache.status": "상태", + "ui.dev.clients.general.public_key.cache.title": "JWKS Cache", + "ui.dev.clients.general.public_key.cache.uri": "JWKS URI", + "ui.dev.clients.general.public_key.guide_toggle": + "ui.dev.clients.general.public_key.guide_toggle", + "ui.dev.clients.general.public_key.headless_disabled": + "ui.dev.clients.general.public_key.headless_disabled", + "ui.dev.clients.general.public_key.headless_enabled": + "ui.dev.clients.general.public_key.headless_enabled", + "ui.dev.clients.general.public_key.headless_toggle": "Headless Login", + "ui.dev.clients.general.public_key.jwks_inline": + "ui.dev.clients.general.public_key.jwks_inline", + "ui.dev.clients.general.public_key.jwks_inline_placeholder": + "ui.dev.clients.general.public_key.jwks_inline_placeholder", + "ui.dev.clients.general.public_key.jwks_uri": "JWKS URI", + "ui.dev.clients.general.public_key.jwks_uri_placeholder": + "https://rp.example.com/.well-known/jwks.json", + "ui.dev.clients.general.public_key.request_object_alg": + "ui.dev.clients.general.public_key.request_object_alg", + "ui.dev.clients.general.public_key.request_object_alg_placeholder": + "ui.dev.clients.general.public_key.request_object_alg_placeholder", + "ui.dev.clients.general.public_key.revoke_cache": "Revoke Cache", + "ui.dev.clients.general.public_key.source": + "ui.dev.clients.general.public_key.source", + "ui.dev.clients.general.public_key.source_uri": + "ui.dev.clients.general.public_key.source_uri", + "ui.dev.clients.general.public_key.title": "공개키 등록", + "ui.dev.clients.general.public_key.validation_title": "저장 전 확인 필요", + "ui.dev.clients.general.redirect.label": "리디렉션 URI", "ui.dev.clients.general.redirect.placeholder": "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)", "ui.dev.clients.general.scopes.add": "Scope 추가", "ui.dev.clients.general.scopes.description_placeholder": "권한에 대한 설명", "ui.dev.clients.general.scopes.name_placeholder": "e.g. profile", - "ui.dev.clients.general.scopes.table.delete": "Delete", - "ui.dev.clients.general.scopes.table.description": "Description", - "ui.dev.clients.general.scopes.table.mandatory": "Mandatory", - "ui.dev.clients.general.scopes.table.name": "Scope Name", - "ui.dev.clients.general.scopes.title": "Scopes", + "ui.dev.clients.general.scopes.table.contact_title": + "ui.dev.clients.general.scopes.table.contact_title", + "ui.dev.clients.general.scopes.table.delete": "삭제", + "ui.dev.clients.general.scopes.table.description": "설명", + "ui.dev.clients.general.scopes.table.invalid_format": + "ui.dev.clients.general.scopes.table.invalid_format", + "ui.dev.clients.general.scopes.table.login_id_help": + "ui.dev.clients.general.scopes.table.login_id_help", + "ui.dev.clients.general.scopes.table.mandatory": "필수", + "ui.dev.clients.general.scopes.table.name": "스코프 이름", + "ui.dev.clients.general.scopes.table.password_title": + "ui.dev.clients.general.scopes.table.password_title", + "ui.dev.clients.general.scopes.table.reset_password": + "ui.dev.clients.general.scopes.table.reset_password", + "ui.dev.clients.general.scopes.table.reset_password_confirm": + "ui.dev.clients.general.scopes.table.reset_password_confirm", + "ui.dev.clients.general.scopes.table.reset_password_label": + "ui.dev.clients.general.scopes.table.reset_password_label", + "ui.dev.clients.general.scopes.table.role_super_admin": + "ui.dev.clients.general.scopes.table.role_super_admin", + "ui.dev.clients.general.scopes.table.role_tenant_admin": + "ui.dev.clients.general.scopes.table.role_tenant_admin", + "ui.dev.clients.general.scopes.table.role_user": + "ui.dev.clients.general.scopes.table.role_user", + "ui.dev.clients.general.scopes.table.status_active": + "ui.dev.clients.general.scopes.table.status_active", + "ui.dev.clients.general.scopes.table.status_inactive": + "ui.dev.clients.general.scopes.table.status_inactive", + "ui.dev.clients.general.scopes.table.status_title": + "ui.dev.clients.general.scopes.table.status_title", + "ui.dev.clients.general.scopes.title": "스코프", + "ui.dev.clients.general.security.headless_login_enable": + "Headless Login (자체 로그인 UI 사용)", + "ui.dev.clients.general.security.headless_login_enable_help": + "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.", "ui.dev.clients.general.security.pkce": "PKCE", "ui.dev.clients.general.security.private": "Server side App", "ui.dev.clients.general.security.title": "보안 설정", - "ui.dev.clients.general.title_create": "Create Client", - "ui.dev.clients.general.title_edit": "Client Settings", + "ui.dev.clients.general.security.trusted_rp_enable": + "ui.dev.clients.general.security.trusted_rp_enable", + "ui.dev.clients.general.security.trusted_rp_enable_help": + "ui.dev.clients.general.security.trusted_rp_enable_help", + "ui.dev.clients.general.subtitle": "앱 설정과 보안 구성을 관리합니다.", + "ui.dev.clients.general.title_create": "연동 앱 생성", + "ui.dev.clients.general.title_edit": "연동 앱 설정", + "ui.dev.clients.help.docs_body": "ui.dev.clients.help.docs_body", "ui.dev.clients.help.docs_title": "Docs & Examples", + "ui.dev.clients.help.subtitle": "ui.dev.clients.help.subtitle", "ui.dev.clients.help.title": "Need help with OIDC configuration?", "ui.dev.clients.help.view_guides": "View guides", "ui.dev.clients.list.title": "연동 앱 목록", @@ -858,17 +1616,22 @@ const Map koStrings = { "ui.dev.clients.owner.scope": "Scope: TENANT-12", "ui.dev.clients.owner.subtitle": "Tenant admin on-call", "ui.dev.clients.owner.title": "Owner", + "ui.dev.clients.registry.description": "ui.dev.clients.registry.description", "ui.dev.clients.registry.subtitle": "연동 앱", "ui.dev.clients.registry.title": "RP registry", + "ui.dev.clients.scopes.email": "ui.dev.clients.scopes.email", + "ui.dev.clients.scopes.openid": "ui.dev.clients.scopes.openid", + "ui.dev.clients.scopes.profile": "ui.dev.clients.scopes.profile", "ui.dev.clients.search_placeholder": "연동 앱 이름/ID로 검색...", "ui.dev.clients.table.actions": "액션", "ui.dev.clients.table.application": "애플리케이션", - "ui.dev.clients.table.client_id": "Client ID", + "ui.dev.clients.table.client_id": "클라이언트 ID", "ui.dev.clients.table.created_at": "생성일", "ui.dev.clients.table.status": "상태", "ui.dev.clients.table.type": "유형", "ui.dev.clients.tenant_scoped": "Tenant-scoped", "ui.dev.clients.type.pkce": "PKCE", + "ui.dev.clients.type.pkce_headless": "Headless PKCE", "ui.dev.clients.type.private": "Server side App", "ui.dev.clients.untitled": "Untitled", "ui.dev.console_title": "Developer Console", @@ -893,24 +1656,51 @@ const Map koStrings = { "ui.dev.header.subtitle": "Manage your applications", "ui.dev.nav.clients": "연동 앱", "ui.dev.nav.logout": "로그아웃", + "ui.dev.profile.basic.email": "이메일", + "ui.dev.profile.basic.id": "사용자 ID", + "ui.dev.profile.basic.name": "이름", + "ui.dev.profile.basic.phone": "전화번호", + "ui.dev.profile.basic.title": "사용자 정보", + "ui.dev.profile.error": "프로필 정보를 불러오지 못했습니다.", + "ui.dev.profile.loading": "프로필 정보를 불러오는 중...", "ui.dev.profile.menu_aria": "계정 메뉴 열기", "ui.dev.profile.menu_title": "계정", + "ui.dev.profile.org.company_code": "회사 코드", + "ui.dev.profile.org.tenant": "테넌트", + "ui.dev.profile.org.title": "조직 정보", + "ui.dev.profile.role.current": "현재 역할", + "ui.dev.profile.role.description": "현재 계정에 부여된 권한 등급입니다.", + "ui.dev.profile.role.title": "시스템 역할", + "ui.dev.profile.subtitle": "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다.", + "ui.dev.profile.tab.basic": "기본 정보", + "ui.dev.profile.tab.role": "권한 및 역할", + "ui.dev.profile.title": "내 정보", "ui.dev.profile.unknown_email": "unknown@example.com", "ui.dev.profile.unknown_name": "Unknown User", "ui.dev.scope_badge": "Scoped to /dev", - "ui.dev.session.active": "만료 시간 확인 중...", + "ui.dev.session.active": "세션 활성", + "ui.dev.session.auto_extend": "세션 만료 관리", + "ui.dev.session.disabled": "자동 연장 비활성화", "ui.dev.session.expired": "세션 만료", "ui.dev.session.expiring": "만료 임박: {{minutes}}분 {{seconds}}초 남음", - "ui.dev.session.refresh": "세션 만료 시간 갱신", - "ui.dev.session.refreshing": "세션 만료 시간 갱신 중...", + "ui.dev.session.refresh": "ui.dev.session.refresh", + "ui.dev.session.refreshing": "ui.dev.session.refreshing", "ui.dev.session.remaining": "만료 예정: {{minutes}}분 {{seconds}}초 남음", - "ui.dev.session.unknown": "확인 불가", + "ui.dev.session.unknown": "알 수 없음", + "ui.dev.tenant.single_notice": "단일 테넌트에 소속되어 전환할 필요가 없습니다.", + "ui.dev.tenant.switch_success": "테넌트 전환 완료", + "ui.dev.tenant.workspace": "작업 테넌트 (컨텍스트)", + "ui.dev.tenant.workspace_desc": "현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다.", "ui.userfront.app_label.admin_console": "Admin Console", "ui.userfront.app_label.baron": "Baron 로그인", "ui.userfront.app_label.dev_console": "Dev Console", "ui.userfront.app_title": "Baron SW 포탈", + "ui.userfront.audit.filter.title": "내 활동 관리", + "ui.userfront.audit.filter.toggle_label": "활성 세션만 보기", + "ui.userfront.audit.table.action": "관리", "ui.userfront.audit.table.app": "애플리케이션", "ui.userfront.audit.table.auth_method": "인증수단", + "ui.userfront.audit.table.browser": "브라우저", "ui.userfront.audit.table.date": "접속일자", "ui.userfront.audit.table.device": "접속환경", "ui.userfront.audit.table.ip": "IP", @@ -920,13 +1710,26 @@ const Map koStrings = { "ui.userfront.audit.table.status": "현황", "ui.userfront.auth_method.ory": "Ory 세션", "ui.userfront.auth_method.session": "세션", + "ui.userfront.consent.accept": "동의하고 계속하기", + "ui.userfront.consent.cancel.confirm_button": "예, 취소합니다", + "ui.userfront.consent.cancel.title": "동의 취소", + "ui.userfront.consent.requested_scopes": "요청된 권한", + "ui.userfront.consent.title": "접근 권한 요청", "ui.userfront.dashboard.activity.linked": "연동됨", "ui.userfront.dashboard.approved_session.default": "승인한 세션 ID", "ui.userfront.dashboard.approved_session.userfront": "승인한 Userfront 세션 ID", "ui.userfront.dashboard.last_auth_label": "최근 인증", + "ui.userfront.dashboard.link_status_label": "연동 상태", "ui.userfront.dashboard.revoke.confirm_button": "해지하기", "ui.userfront.dashboard.revoke.title": "연동 해지", - "ui.userfront.dashboard.scopes.title": "권한 (Scopes)", + "ui.userfront.dashboard.scopes.title": "동의 범위", + "ui.userfront.dashboard.sessions.active_badge": "활성화", + "ui.userfront.dashboard.sessions.current_badge": "접속중", + "ui.userfront.dashboard.sessions.current_disabled": "현재 세션", + "ui.userfront.dashboard.sessions.revoke.action": "세션 종료", + "ui.userfront.dashboard.sessions.revoke.title": "세션 종료", + "ui.userfront.dashboard.sessions.unknown_device": "알 수 없는 기기", + "ui.userfront.dashboard.sessions.unknown_session": "세션 정보", "ui.userfront.dashboard.status.revoked": "해지됨", "ui.userfront.dashboard.status_history": "상태 이력", "ui.userfront.device.android": "Mobile(Android)", @@ -944,12 +1747,13 @@ const Map koStrings = { "ui.userfront.login.field.login_id": "이메일 또는 휴대폰 번호", "ui.userfront.login.field.password": "비밀번호", "ui.userfront.login.forgot_password": "비밀번호를 잊으셨나요?", - "ui.userfront.login.link.action_label": "로그인 화면으로 이동", + "ui.userfront.login.link.action_label": + "ui.userfront.login.link.action_label", "ui.userfront.login.link.code_only": "코드만 받기({{time}})", - "ui.userfront.login.link.page_title": "링크 로그인", + "ui.userfront.login.link.page_title": "ui.userfront.login.link.page_title", "ui.userfront.login.link.resend_with_time": "재발송 ({{time}})", "ui.userfront.login.link.send": "로그인 링크 전송", - "ui.userfront.login.link.title": "링크 로그인 완료", + "ui.userfront.login.link.title": "ui.userfront.login.link.title", "ui.userfront.login.qr.expired": "QR 코드 만료됨", "ui.userfront.login.qr.refresh": "QR 코드 새로고침", "ui.userfront.login.qr.remaining": "남은 시간: {{time}}", @@ -993,10 +1797,10 @@ const Map koStrings = { "ui.userfront.profile.section.basic": "기본 정보", "ui.userfront.profile.section.organization": "조직 정보", "ui.userfront.profile.section.security": "보안", - "ui.userfront.profile.user_fallback": "User", - "ui.userfront.qr.request_permission": "카메라 권한 요청하기", + "ui.userfront.profile.user_fallback": "사용자", + "ui.userfront.qr.request_permission": "ui.userfront.qr.request_permission", "ui.userfront.qr.rescan": "다시 스캔", - "ui.userfront.qr.result_failure": "승인 실패", + "ui.userfront.qr.result_failure": "ui.userfront.qr.result_failure", "ui.userfront.qr.result_success": "승인 완료", "ui.userfront.qr.title": "Scan QR Code", "ui.userfront.reset.confirm_password": "새 비밀번호 확인", @@ -1006,10 +1810,12 @@ const Map koStrings = { "ui.userfront.reset.title": "새 비밀번호 설정", "ui.userfront.sections.apps": "나의 App 현황", "ui.userfront.sections.audit": "접속이력", + "ui.userfront.sections.sessions": "활성 세션", "ui.userfront.session.active": "세션 활성", "ui.userfront.session.unknown": "알 수 없음", "ui.userfront.signup.agreement.all": "모두 동의합니다", "ui.userfront.signup.agreement.privacy_title": "개인정보 수집 및 이용 동의 (필수)", + "ui.userfront.signup.agreement.required": "필수", "ui.userfront.signup.agreement.tos_title": "바론 소프트웨어 이용약관 (필수)", "ui.userfront.signup.auth.code_label": "인증코드 6자리", "ui.userfront.signup.auth.email.label": "이메일 주소", @@ -1069,146 +1875,291 @@ const Map enStrings = { "err.backend.slow_down": "Requests are too frequent. Please try again shortly.", "err.common.unknown": "An unknown error occurred.", - "err.userfront.auth_proxy.consent_accept": "Consent Accept", - "err.userfront.auth_proxy.consent_fetch": "Consent Fetch", - "err.userfront.auth_proxy.consent_reject": "Consent Reject", - "err.userfront.auth_proxy.linked_app_revoke": "Linked App Revoke", - "err.userfront.auth_proxy.login_failed": "Login Failed", + "err.userfront.auth_proxy.consent_accept": + "Failed to accept the consent request.", + "err.userfront.auth_proxy.consent_fetch": "Failed to load consent details.", + "err.userfront.auth_proxy.consent_reject": + "Failed to reject the consent request.", + "err.userfront.auth_proxy.linked_app_revoke": + "Failed to revoke the linked application.", + "err.userfront.auth_proxy.login_failed": "Login failed.", "err.userfront.auth_proxy.oidc_accept": "OIDC Accept", - "err.userfront.auth_proxy.password_reset_complete": "Password Reset Complete", - "err.userfront.auth_proxy.password_reset_init": "Password Reset Init", - "err.userfront.profile.load_failed": "Load Failed", + "err.userfront.auth_proxy.password_reset_complete": + "Failed to complete the password reset.", + "err.userfront.auth_proxy.password_reset_init": + "Failed to start the password reset.", + "err.userfront.profile.load_failed": "Failed to load the profile.", "err.userfront.profile.password_change_failed": "Password Change Failed", - "err.userfront.profile.send_code_failed": "Send Code Failed", - "err.userfront.profile.update_failed": "Update Failed", - "err.userfront.profile.verify_code_failed": "Verify Code Failed", - "err.userfront.session.missing": "Missing", - "msg.admin.api_keys.create.error": "Error", - "msg.admin.api_keys.create.name_required": "Name Required", - "msg.admin.api_keys.create.scope_required": "Scope Required", - "msg.admin.api_keys.create.scopes_count": "Scopes Count", - "msg.admin.api_keys.create.scopes_hint": "Scopes Hint", - "msg.admin.api_keys.create.subtitle": "Subtitle", - "msg.admin.api_keys.create.success.copy_hint": "Copy Hint", - "msg.admin.api_keys.create.success.notice": "Notice", - "msg.admin.api_keys.create.success.notice_emphasis": "Notice Emphasis", - "msg.admin.api_keys.create.success.notice_suffix": "Notice Suffix", - "msg.admin.api_keys.list.delete_confirm": "Delete Confirm", - "msg.admin.api_keys.list.empty": "Empty", - "msg.admin.api_keys.list.fetch_error": "Fetch Error", - "msg.admin.api_keys.list.registry.count": "Count", - "msg.admin.api_keys.list.subtitle": "Subtitle", - "msg.admin.audit.empty": "Empty", + "err.userfront.profile.send_code_failed": + "Failed to send the verification code.", + "err.userfront.profile.update_failed": "Failed to update the profile.", + "err.userfront.profile.verify_code_failed": "Verification failed.", + "err.userfront.session.missing": "No active session was found.", + "msg.admin.api_keys.create.error": "Failed to create the API key.", + "msg.admin.api_keys.create.name_required": "Name is required.", + "msg.admin.api_keys.create.scope_required": "Select at least one scope.", + "msg.admin.api_keys.create.scopes_count": + "{{count}} scopes will be assigned.", + "msg.admin.api_keys.create.scopes_hint": + "Choose the scopes to grant to this API key.", + "msg.admin.api_keys.create.subtitle": + "Create and issue an API key for machine-to-machine communication.", + "msg.admin.api_keys.create.success.copy_hint": + "Copy the secret now. It will not be shown again.", + "msg.admin.api_keys.create.success.notice": + "The generated secret is displayed only once.", + "msg.admin.api_keys.create.success.notice_emphasis": + "Store it in a secure location.", + "msg.admin.api_keys.create.success.notice_suffix": + "Rotate the key immediately if you think it has been exposed.", + "msg.admin.api_keys.list.delete_confirm": + "Are you sure you want to delete this API key?", + "msg.admin.api_keys.list.empty": "No API keys have been issued yet.", + "msg.admin.api_keys.list.fetch_error": "Failed to load the API key list.", + "msg.admin.api_keys.list.registry.count": "{{count}} API keys loaded.", + "msg.admin.api_keys.list.subtitle": + "View and manage the API keys issued for server-to-server communication.", + "msg.admin.audit.empty": "No audit logs have been collected yet.", "msg.admin.audit.end": "End of audit feed", - "msg.admin.audit.filters.empty": "Empty", + "msg.admin.audit.filters.empty": "No filters applied.", "msg.admin.audit.load_error": "Error loading logs: {{error}}", "msg.admin.audit.loading": "Loading audit logs...", - "msg.admin.audit.registry.count": "Count", - "msg.admin.audit.subtitle": "Subtitle", - "msg.admin.groups.list.create_error": "Create Failed", - "msg.admin.groups.list.create_success": "Create Success", - "msg.admin.groups.list.delete_confirm": "Delete Confirm", - "msg.admin.groups.list.delete_error": "Delete Error", - "msg.admin.groups.list.delete_success": "Delete Success", - "msg.admin.groups.list.empty": "Empty", + "msg.admin.audit.registry.count": "{{count}} logs loaded.", + "msg.admin.audit.subtitle": + "Review command-driven ClickHouse audit logs from the admin workspace.", + "msg.admin.common.forbidden": + "You do not have permission to perform this action.", + "msg.admin.groups.create.description": + "Adds a new organization unit such as a department or team.", + "msg.admin.groups.create.title": "Create New Organization Unit", + "msg.admin.groups.list.create_error": + "Failed to create the organization unit.", + "msg.admin.groups.list.create_success": + "Organization unit created successfully.", + "msg.admin.groups.list.delete_confirm": + "Are you sure you want to delete this organization unit?", + "msg.admin.groups.list.delete_error": + "Failed to delete the organization unit.", + "msg.admin.groups.list.delete_success": + "Organization unit deleted successfully.", + "msg.admin.groups.list.empty": + "No organization units have been registered yet.", "msg.admin.groups.list.import_error": "Import Error", "msg.admin.groups.list.import_success": "Import Success", "msg.admin.groups.list.loading": "Loading...", - "msg.admin.groups.list.subtitle": "Subtitle", - "msg.admin.groups.members.add_success": "Add Success", - "msg.admin.groups.members.count": "Count", - "msg.admin.groups.members.empty": "Empty", - "msg.admin.groups.members.remove_confirm": "Remove Confirm", - "msg.admin.groups.members.remove_success": "Remove Success", - "msg.admin.groups.members.title": "Title", - "msg.admin.groups.prompt.user_id": "User Id", + "msg.admin.groups.list.subtitle": + "Manage departments and teams under the current tenant.", + "msg.admin.groups.members.add_success": "Member added successfully.", + "msg.admin.groups.members.count": "{{count}} members loaded.", + "msg.admin.groups.members.empty": + "No members are assigned to this organization unit.", + "msg.admin.groups.members.remove_confirm": + "Are you sure you want to remove this member?", + "msg.admin.groups.members.remove_success": "Member removed successfully.", + "msg.admin.groups.members.title": "Member Management", + "msg.admin.groups.prompt.user_id": "Enter the user's UUID to add:", "msg.admin.groups.roles.assign_success": "Assign Success", - "msg.admin.groups.roles.description": "Description", - "msg.admin.groups.roles.empty": "Empty", + "msg.admin.groups.roles.description": + "Assign or revoke roles for members of this organization unit.", + "msg.admin.groups.roles.empty": "No roles have been assigned yet.", "msg.admin.groups.roles.remove_confirm": - "msg.admin.groups.roles.remove_confirm", - "msg.admin.groups.roles.remove_success": "Remove Success", + "Are you sure you want to revoke this role?", + "msg.admin.groups.roles.remove_success": "Role revoked successfully.", "msg.admin.header.subtitle": "Tenant isolation & least privilege by default", "msg.admin.idp_env_prod": "IDP env: prod", "msg.admin.logout_confirm": "Are you sure you want to log out?", - "msg.admin.notice.idp_policy": "IDP Policy", - "msg.admin.notice.scope": "Scope", - "msg.admin.overview.description": "Description", + "msg.admin.notice.idp_policy": + "IDP management keys are only used through server-side wrapper APIs with audit logging and rate limits enabled.", + "msg.admin.notice.scope": + "Administrative features are exposed only within the /admin namespace.", + "msg.admin.org.hover_member_info": "Hover to see member details.", + "msg.admin.org.import_description": + "Upload a CSV file to bulk register the organization chart.", + "msg.admin.org.import_error": + "An error occurred during organization chart import.", + "msg.admin.org.import_success": "Organization chart imported successfully.", + "msg.admin.overview.description": + "Review shared metrics and policy status across all tenants in one place.", "msg.admin.overview.idp_fallback": "Fallback: Descope", "msg.admin.overview.idp_primary": "IDP: Ory primary", - "msg.admin.overview.playbook.description": "Description", - "msg.admin.overview.playbook.idp_body": "IDP Body", + "msg.admin.overview.playbook.description": + "Operational guardrails and architecture decisions for the admin control plane.", + "msg.admin.overview.playbook.idp_body": + "All IDP calls are routed through the backend only. Hydra and Kratos admin ports are never exposed publicly.", "msg.admin.overview.playbook.idp_title": "Backend-only IDP access", - "msg.admin.overview.playbook.tenant_body": "Tenant Body", + "msg.admin.overview.playbook.tenant_body": + "Tenant headers and audit logging are enabled by default and can later be extended with Keto policies.", "msg.admin.overview.playbook.tenant_title": "Tenant isolation", - "msg.admin.overview.quick_links.description": "Description", + "msg.admin.overview.quick_links.description": + "Jump to the most frequently used administrative workflows.", + "msg.admin.overview.summary.audit_events_24h": "24h Audit Events", + "msg.admin.overview.summary.oidc_clients": "OIDC Clients", + "msg.admin.overview.summary.policy_gate": "Policy Gate Status", + "msg.admin.overview.summary.total_tenants": "Total Tenants", "msg.admin.scope_admin": "Scoped to /admin", "msg.admin.session_ttl": "Session TTL: 15m admin", "msg.admin.tenant_headers": "Tenant-aware headers", - "msg.admin.tenants.admins.add_success": "Add Success", - "msg.admin.tenants.admins.empty": "Empty", - "msg.admin.tenants.admins.remove_confirm": "Remove Confirm", - "msg.admin.tenants.admins.remove_success": "Remove Success", - "msg.admin.tenants.admins.subtitle": "Subtitle", - "msg.admin.tenants.approve_confirm": "Approve Confirm", - "msg.admin.tenants.approve_success": "Approve Success", + "msg.admin.tenants.admins.add_success": "Tenant admin added successfully.", + "msg.admin.tenants.admins.empty": "No tenant admins are assigned yet.", + "msg.admin.tenants.admins.remove_confirm": + "Are you sure you want to remove this tenant admin?", + "msg.admin.tenants.admins.remove_last": "Cannot remove the last admin.", + "msg.admin.tenants.admins.remove_self": "Cannot remove yourself.", + "msg.admin.tenants.admins.remove_success": + "Tenant admin removed successfully.", + "msg.admin.tenants.admins.subtitle": + "Manage the administrators assigned to this tenant.", + "msg.admin.tenants.approve_confirm": "Do you want to approve this tenant?", + "msg.admin.tenants.approve_success": "Tenant approved successfully.", "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", - "msg.admin.tenants.create.memo.subtitle": "Subtitle", - "msg.admin.tenants.create.profile.subtitle": "Subtitle", - "msg.admin.tenants.create.subtitle": "Subtitle", - "msg.admin.tenants.delete_confirm": "Delete Tenant \"{{name}}\"?", + "msg.admin.tenants.create.memo.body": + "Leave operational notes or policy reminders for this tenant.", + "msg.admin.tenants.create.memo.subtitle": + "Capture internal policy notes for administrators.", + "msg.admin.tenants.create.profile.subtitle": + "Set the basic tenant profile information.", + "msg.admin.tenants.create.subtitle": + "Enter the minimum required information to create a tenant.", + "msg.admin.tenants.delete_confirm": "Delete Tenant \\\\\\\"{{name}}\\\\\\\"?", "msg.admin.tenants.delete_success": "Tenant deleted.", - "msg.admin.tenants.empty": "Empty", - "msg.admin.tenants.fetch_error": "Fetch Error", - "msg.admin.tenants.members.empty": "Empty", + "msg.admin.tenants.empty": "No tenants have been registered yet.", + "msg.admin.tenants.fetch_error": "Failed to load the tenant list.", + "msg.admin.tenants.members.desc": + "View the list of users belonging to this organization.", + "msg.admin.tenants.members.empty": "No members found.", + "msg.admin.tenants.members.limit_notice": + "Showing members from the first 10 descendant organizations due to size limits.", "msg.admin.tenants.missing_id": "No Tenant ID.", - "msg.admin.tenants.registry.count": "Count", + "msg.admin.tenants.not_found": "Tenant not found.", + "msg.admin.tenants.owners.add_success": "Owner added successfully.", + "msg.admin.tenants.owners.empty": "No owners registered.", + "msg.admin.tenants.owners.remove_confirm": + "Are you sure you want to remove this owner?", + "msg.admin.tenants.owners.remove_last": "Cannot remove the last owner.", + "msg.admin.tenants.owners.remove_self": "Cannot remove yourself.", + "msg.admin.tenants.owners.remove_success": "Owner permission revoked.", + "msg.admin.tenants.owners.subtitle": + "List of owners with top-level permissions for this tenant.", + "msg.admin.tenants.registry.count": "{{count}} tenants loaded.", + "msg.admin.tenants.remove_sub_confirm": + "Remove tenant \\\"{{name}}\\\" from sub-tenants?", "msg.admin.tenants.schema.empty": - "No custom fields defined. Click \"Add Field\" to begin.", + "No custom fields defined. Click \\\\\\\"Add Field\\\\\\\" to begin.", + "msg.admin.tenants.schema.forbidden_desc": + "Only administrators can access user schema settings.", "msg.admin.tenants.schema.missing_id": "Tenant ID missing", "msg.admin.tenants.schema.subtitle": "Define custom attributes for users in this tenant.", "msg.admin.tenants.schema.update_error": "Failed to update schema", "msg.admin.tenants.schema.update_success": "Schema updated successfully", - "msg.admin.tenants.sub.empty": "Empty", - "msg.admin.tenants.sub.subtitle": "Subtitle", - "msg.admin.tenants.subtitle": "Subtitle", - "msg.admin.users.create.account.subtitle": "Subtitle", + "msg.admin.tenants.sub.empty": "No child tenants are connected.", + "msg.admin.tenants.sub.subtitle": + "Review and manage child tenants linked under this tenant.", + "msg.admin.tenants.subtitle": + "Review registered tenants and manage their current status.", + "msg.admin.users.bulk.delete_confirm": + "Are you sure you want to delete the selected {{count}} users?", + "msg.admin.users.bulk.delete_success": "{{count}} users have been deleted.", + "msg.admin.users.bulk.description": + "Bulk register or manage users via CSV file.", + "msg.admin.users.bulk.move_description": + "Bulk move selected users to another tenant.", + "msg.admin.users.bulk.move_error": "Error moving users.", + "msg.admin.users.bulk.move_success": "{{count}} users moved successfully.", + "msg.admin.users.bulk.parsed_count": "Parsed {{count}} rows.", + "msg.admin.users.bulk.schema_incompatible": + "Fields not in target schema may be lost:", + "msg.admin.users.bulk.schema_missing": + "Missing required fields for target tenant:", + "msg.admin.users.bulk.update_success": "User info updated successfully.", + "msg.admin.users.create.account.subtitle": + "Fill in the account details required to create the user.", "msg.admin.users.create.error": "Failed to User Create.", "msg.admin.users.create.form.email_required": "Email Required", - "msg.admin.users.create.form.name_required": "Name Required", + "msg.admin.users.create.form.field_invalid": "Invalid {{label}} format.", + "msg.admin.users.create.form.field_required": "{{label}} is required.", + "msg.admin.users.create.form.login_id_help": + "msg.admin.users.create.form.login_id_help", + "msg.admin.users.create.form.name_required": "Name is required.", "msg.admin.users.create.form.password_auto_help": "Password Auto Help", "msg.admin.users.create.form.password_manual_help": "Password Manual Help", "msg.admin.users.create.form.role_help": "Role Help", "msg.admin.users.create.password_generated.default": "Default", "msg.admin.users.create.password_generated.with_email": "With Email", "msg.admin.users.create.password_required": "Password Required", + "msg.admin.users.create.success": "User created successfully.", + "msg.admin.users.detail.delete_confirm": + "Are you sure you want to delete this user?", + "msg.admin.users.detail.delete_error": "msg.admin.users.detail.delete_error", + "msg.admin.users.detail.delete_success": "User deleted.", "msg.admin.users.detail.edit_subtitle": "Edit Subtitle", - "msg.admin.users.detail.form.name_required": "Name Required", + "msg.admin.users.detail.form.field_required": "Required.", + "msg.admin.users.detail.form.invalid_format": "Invalid Format", + "msg.admin.users.detail.form.name_required": "Name is required.", + "msg.admin.users.detail.history_desc": "History Desc", + "msg.admin.users.detail.no_history": "No History", + "msg.admin.users.detail.no_tenants": "No Tenants", "msg.admin.users.detail.not_found": "Not Found", + "msg.admin.users.detail.password_generated": + "A secure password has been generated.", + "msg.admin.users.detail.password_generated_help": + "Generate a temporary password that meets the security policy and apply it immediately.", + "msg.admin.users.detail.password_manual_required": "Please enter a password.", + "msg.admin.users.detail.reset_auto_desc": "Reset Auto Desc", + "msg.admin.users.detail.reset_password_confirm": + "msg.admin.users.detail.reset_password_confirm", + "msg.admin.users.detail.reset_password_help": + "Force-reset the user's password and apply either an auto-generated password or a manually entered one.", "msg.admin.users.detail.security.password_hint": "Password Hint", + "msg.admin.users.detail.security_desc": "Security Desc", + "msg.admin.users.detail.self_password_reset_blocked": + "Please change your own password from the UserFront settings page.", + "msg.admin.users.detail.tenant_slug_help": "Tenant Slug Help", + "msg.admin.users.detail.tenants_desc": "Tenants Desc", "msg.admin.users.detail.update_error": "Failed to User Edit.", "msg.admin.users.detail.update_success": "Update Success", - "msg.admin.users.list.delete_confirm": "Delete Confirm", - "msg.admin.users.list.empty": "Empty", - "msg.admin.users.list.fetch_error": "Fetch Error", - "msg.admin.users.list.registry.count": "Count", - "msg.admin.users.list.subtitle": "Subtitle", - "msg.common.error": "Error", + "msg.admin.users.list.columns.description": + "Select columns to display in the table.", + "msg.admin.users.list.columns.no_custom": + "No custom fields defined for this tenant.", + "msg.admin.users.list.delete_confirm": + "Are you sure you want to delete the selected user?", + "msg.admin.users.list.empty": "No users match the current filters.", + "msg.admin.users.list.fetch_error": "Failed to load the user list.", + "msg.admin.users.list.registry.count": "{{count}} users loaded.", + "msg.admin.users.list.subtitle": + "Search and manage users registered in the current tenant.", + "msg.common.copied": "Copied", + "msg.common.copied_to_clipboard": "Copied to clipboard.", + "msg.common.error": "An error occurred.", + "msg.common.forbidden": "Access Denied.", "msg.common.loading": "Loading...", "msg.common.no_description": "No Description.", + "msg.common.parsing": "Parsing data...", "msg.common.requesting": "Requesting...", "msg.common.saving": "Saving...", "msg.common.unknown_error": "unknown error", + "msg.dev.audit.empty": "No audit logs found.", + "msg.dev.audit.forbidden": + "You do not have permission to view audit logs. Please request access from an administrator.", + "msg.dev.audit.load_error": "Error loading audit logs: {{error}}", + "msg.dev.audit.loaded_count": "Loaded {{count}} rows", + "msg.dev.audit.loading": "Loading audit logs...", + "msg.dev.audit.subtitle": + "Shows DevFront activity history within current tenant/app scope.", + "msg.dev.auth.access_denied_description": + "DevFront is for administrators only. Request access from your administrator.", + "msg.dev.auth.access_denied_title": "Access denied.", "msg.dev.clients.consents.empty": "No consents found.", "msg.dev.clients.consents.load_error": "Error loading consents: {{error}}", "msg.dev.clients.consents.loading": "Loading consents...", + "msg.dev.clients.consents.revoke_confirm": + "Are you sure you want to revoke this user's permissions? After revocation, the user must consent again on next login.", "msg.dev.clients.consents.showing": "Showing {{from}} to {{to}} of {{total}} users", - "msg.dev.clients.consents.subtitle": "Subtitle", - "msg.dev.clients.copy_client_id": "Copy Client Id", + "msg.dev.clients.consents.subtitle": + "Review consent grants and users who have approved this application.", "msg.dev.clients.delete_confirm": "Are you sure you want to delete this app? This action cannot be undone.", "msg.dev.clients.delete_error": "Failed to delete: {{error}}", @@ -1219,31 +2170,117 @@ const Map enStrings = { "msg.dev.clients.details.load_error": "Error loading client: {{error}}", "msg.dev.clients.details.loading": "Loading client...", "msg.dev.clients.details.missing_id": "Client ID is required.", - "msg.dev.clients.details.redirect.description": "Description", + "msg.dev.clients.details.redirect.description": + "List the allowed URLs that users can be redirected to after authentication. Separate multiple values with commas.", "msg.dev.clients.details.redirect_saved": "Redirect URIs saved.", "msg.dev.clients.details.rotate_confirm": "Rotate Confirm", "msg.dev.clients.details.rotate_error": "Rotate Error", "msg.dev.clients.details.save_error": "Save Error", "msg.dev.clients.details.secret_rotated": "Secret Rotated", - "msg.dev.clients.details.secret_unavailable": "SECRET_NOT_AVAILABLE", - "msg.dev.clients.details.security.footer": "Footer", - "msg.dev.clients.details.security.note": "Note", - "msg.dev.clients.details.subtitle": "Subtitle", + "msg.dev.clients.details.secret_unavailable": + "The client secret is not available.", + "msg.dev.clients.details.security.footer": + "When rotating a secret, confirm the admin session TTL, rate limits, and notification flow.", + "msg.dev.clients.details.security.note": + "Keep endpoints read-only and link secret copy or rotation actions to audit logs.", + "msg.dev.clients.details.subtitle": + "Inspect this application's credentials, endpoints, and security settings.", "msg.dev.clients.federation.add_subtitle": "Connect an external OIDC provider.", "msg.dev.clients.federation.empty": "No IdP configurations found.", "msg.dev.clients.federation.subtitle": "Manage external identity providers for this application.", "msg.dev.clients.general.identity.logo_help": "Logo Help", - "msg.dev.clients.general.identity.subtitle": "Subtitle", + "msg.dev.clients.general.identity.subtitle": + "Manage the OIDC identity, branding, and basic metadata for this application.", "msg.dev.clients.general.load_error": "Error loading client: {{error}}", "msg.dev.clients.general.loading": "Loading client...", + "msg.dev.clients.general.public_key.allowed_algorithms_tooltip": + "Allowed Algorithms Tooltip", + "msg.dev.clients.general.public_key.auth_method_client_secret_basic_help": + "Standard authentication method for server-side applications.", + "msg.dev.clients.general.public_key.auth_method_none_help": + "Use this for PKCE-based public clients.", + "msg.dev.clients.general.public_key.auth_method_private_key_jwt_help": + "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification.", + "msg.dev.clients.general.public_key.cache.missing_algorithm_badge": + "Missing Algorithm Badge", + "msg.dev.clients.general.public_key.cache.missing_algorithm_reason": + "Missing Algorithm Reason", + "msg.dev.clients.general.public_key.cache.missing_algorithms_help": + "Missing Algorithms Help", + "msg.dev.clients.general.public_key.cache.missing_algorithms_title": + "Missing Algorithms Title", + "msg.dev.clients.general.public_key.cache.parsed_keys_empty": + "No parsed JWKS keys are available yet.", + "msg.dev.clients.general.public_key.cache.parsed_keys_help": + "Raw JWKS stays hidden. Only parsed key metadata is shown here.", + "msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason": + "Unsupported Algorithm Reason", + "msg.dev.clients.general.public_key.cache.unsupported_algorithms_help": + "Unsupported Algorithms Help", + "msg.dev.clients.general.public_key.cache.unsupported_algorithms_title": + "Unsupported Algorithms Title", + "msg.dev.clients.general.public_key.cache_empty": "Cache Empty", + "msg.dev.clients.general.public_key.cache_help": "Cache Help", + "msg.dev.clients.general.public_key.cache_refresh_failed": + "Cache Refresh Failed", + "msg.dev.clients.general.public_key.cache_refreshed": "Cache Refreshed", + "msg.dev.clients.general.public_key.cache_revoke_confirm": + "Cache Revoke Confirm", + "msg.dev.clients.general.public_key.cache_revoke_failed": + "Cache Revoke Failed", + "msg.dev.clients.general.public_key.cache_revoked": "Cache Revoked", + "msg.dev.clients.general.public_key.guide_example": + "Recommended example: https://rp.example.com/.well-known/jwks.json", + "msg.dev.clients.general.public_key.guide_intro": + "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key.", + "msg.dev.clients.general.public_key.guide_step_1": + "Generate a key pair on the RP server and keep the private key only in the RP backend.", + "msg.dev.clients.general.public_key.guide_step_2": + "Expose the public key from the RP backend through a JWKS (JSON Web Key Set) endpoint.", + "msg.dev.clients.general.public_key.guide_step_3": + "Enter a URL such as https://rp.example.com/.well-known/jwks.json in DevFront.", + "msg.dev.clients.general.public_key.headless_help": + "You can design your own login UI within the application. While the UI is yours, the actual identity verification and security checks are handled in the background via Baron's API.", + "msg.dev.clients.general.public_key.jwks_inline_help": + "Prefer the SSH-RSA public key format first. If you paste an 'ssh-rsa AAA...' key, Baron converts it to OIDC-standard JWKS (JSON) before saving.", + "msg.dev.clients.general.public_key.jwks_uri_help": + "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json", + "msg.dev.clients.general.public_key.request_object_alg_help": + "Specify the JAR (Request Object) signing algorithm used for headless login.", + "msg.dev.clients.general.public_key.source_help": + "Register the JWKS URI served by the RP so Baron can verify the public key.", + "msg.dev.clients.general.public_key.subtitle": + "Manage the public key and headless login settings required for trusted RP evaluation.", + "msg.dev.clients.general.public_key.validation.headless_requires_alg": + "Headless login requires a Request Object Signing Algorithm.", + "msg.dev.clients.general.public_key.validation.headless_requires_private_key_jwt": + "Headless login requires token endpoint auth method to be private_key_jwt.", + "msg.dev.clients.general.public_key.validation.headless_requires_public_key": + "Headless login requires a JWKS URI.", + "msg.dev.clients.general.public_key.validation.invalid_jwks_inline": + "The input must be valid JSON (JWKS). For SSH-RSA input, it must start with 'ssh-rsa'.", + "msg.dev.clients.general.public_key.validation.invalid_jwks_uri": + "JWKS URI format is invalid.", + "msg.dev.clients.general.public_key.validation.missing_jwks_inline": + "Enter a public key in SSH-RSA or JWKS format.", + "msg.dev.clients.general.public_key.validation.missing_jwks_uri": + "JWKS URI is required.", + "msg.dev.clients.general.public_key.validation.missing_parsed_algorithms": + "Missing Parsed Algorithms", + "msg.dev.clients.general.public_key.validation.private_key_jwt_requires_public_key": + "Signed key-based authentication requires a JWKS URI.", + "msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms": + "Unsupported Parsed Algorithms", "msg.dev.clients.general.redirect.help": "Enter the redirect URIs. You can modify them in the Federation tab after creation.", "msg.dev.clients.general.save_error": "Failed to save: {{error}}", "msg.dev.clients.general.saved": "Saved", - "msg.dev.clients.general.scopes.empty": "Empty", - "msg.dev.clients.general.scopes.subtitle": "Subtitle", + "msg.dev.clients.general.scopes.empty": + "No custom scopes have been added yet.", + "msg.dev.clients.general.scopes.subtitle": + "Define the scopes this application can request.", "msg.dev.clients.general.security.pkce_help": "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory.", "msg.dev.clients.general.security.private_help": @@ -1257,60 +2294,124 @@ const Map enStrings = { "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.", "msg.dev.clients.load_error": "Error loading clients: {{error}}", "msg.dev.clients.loading": "Loading apps...", - "msg.dev.clients.registry.description": "Description", + "msg.dev.clients.registry.description": + "Manage OIDC applications, authentication methods, redirect URIs, and secret key reissue with audit logs.", "msg.dev.clients.scopes.email": "Email", "msg.dev.clients.scopes.openid": "Openid", "msg.dev.clients.scopes.profile": "Profile", "msg.dev.clients.showing": "Showing {{shown}} of {{total}} apps", - "msg.dev.clients.status_update_error": "Failed to update client status", - "msg.dev.clients.status_updated": "The app has been {{status}}.", - "msg.dev.dashboard.hero.body": "Body", + "msg.dev.dashboard.hero.body": + "Monitor RP readiness, consent activity, and operational status for the current developer workspace.", "msg.dev.dashboard.hero.title_emphasis": "Title Emphasis", "msg.dev.dashboard.hero.title_prefix": "Title Prefix", "msg.dev.dashboard.hero.title_suffix": "Title Suffix", "msg.dev.dashboard.notice.consent_audit": "Consent Audit", "msg.dev.dashboard.notice.dev_scope": "Dev Scope", "msg.dev.dashboard.notice.hydra_health": "Hydra Health", + "msg.dev.forbidden.default": + "You do not have permission to access this resource. Please contact an administrator.", + "msg.dev.forbidden.rp_admin": + "RP administrators can only access resources for the apps they manage.", + "msg.dev.forbidden.tenant_admin": + "Tenant administrator permissions are not configured correctly or have expired.", + "msg.dev.forbidden.title": "Access Denied: {{resource}}", + "msg.dev.forbidden.user": + "Regular users cannot access the developer console.", "msg.dev.logout_confirm": "Are you sure you want to log out?", - "msg.dev.sidebar.notice": "Notice", - "msg.dev.sidebar.notice_detail": "Notice Detail", + "msg.dev.sidebar.notice": "Developer Console", + "msg.dev.sidebar.notice_detail": "Register and manage client applications.", "msg.info.saved_success": "Saved successfully.", - "msg.userfront.audit.date": "Date", - "msg.userfront.audit.device": "Device", - "msg.userfront.audit.end": "End", - "msg.userfront.audit.ip": "Ip", - "msg.userfront.audit.load_more_error": "Load More Error", - "msg.userfront.audit.result": "Result", + "msg.userfront.audit.browser": "Browser: {{value}}", + "msg.userfront.audit.date": "Date: {{value}}", + "msg.userfront.audit.device": "Device: {{value}}", + "msg.userfront.audit.end": "No more items to show.", + "msg.userfront.audit.filter.description": + "Toggle to view only active sessions.", + "msg.userfront.audit.filtered_empty": + "No sign-in history matches the active session filter.", + "msg.userfront.audit.ip": "IP address: {{value}}", + "msg.userfront.audit.load_more_error": "Could not load more history.", + "msg.userfront.audit.result": "Result: {{value}}", "msg.userfront.audit.session_id": "Session ID: {{value}}", - "msg.userfront.audit.status": "Status", - "msg.userfront.dashboard.activities.empty": "Empty", - "msg.userfront.dashboard.activities.empty_detail": "Empty Detail", - "msg.userfront.dashboard.activities.error": "Error", - "msg.userfront.dashboard.approved_device": "Approved Device", - "msg.userfront.dashboard.approved_ip": "Approve IP: {{ip}}", - "msg.userfront.dashboard.approved_session.copy_click": "Copy Click", - "msg.userfront.dashboard.approved_session.copy_tap": "Copy Tap", - "msg.userfront.dashboard.approved_session.none": "None", - "msg.userfront.dashboard.audit_empty": "Audit Empty", - "msg.userfront.dashboard.audit_load_error": "Audit Load Error", - "msg.userfront.dashboard.auth_method": "Auth Method", + "msg.userfront.audit.status": "Status: pending", + "msg.userfront.consent.accept_error": "Failed to process consent: {{error}}", + "msg.userfront.consent.cancel.confirm": + "If you cancel consent, you will not be able to use this service. Do you want to cancel?", + "msg.userfront.consent.cancel.error": + "An error occurred while cancelling consent: {{error}}", + "msg.userfront.consent.client_id": "Client ID: {{id}}", + "msg.userfront.consent.client_unknown": "Unknown application", + "msg.userfront.consent.description": + "The service below is requesting access to your account information.\\\\nPlease choose whether to continue.", + "msg.userfront.consent.load_error": + "Failed to load consent information: {{error}}", + "msg.userfront.consent.missing_redirect": + "Consent was processed, but the redirect URL was missing.", + "msg.userfront.consent.redirect_notice": + "After consent, you will be redirected automatically.", + "msg.userfront.consent.scope.email": + "Email address (account identification and notifications)", + "msg.userfront.consent.scope.offline_access": + "Offline access (keep signed in)", + "msg.userfront.consent.scope.openid": + "OpenID authentication information (signin session check)", + "msg.userfront.consent.scope.phone": + "Phone number (identity verification and notifications)", + "msg.userfront.consent.scope.profile": + "Basic profile information (name, user identifier)", + "msg.userfront.consent.scope_count": "Total {{count}}", + "msg.userfront.dashboard.activities.empty": "No linked apps yet.", + "msg.userfront.dashboard.activities.empty_detail": + "Linked apps and their latest activity will appear here.", + "msg.userfront.dashboard.activities.error": "Could not load linked apps.", + "msg.userfront.dashboard.approved_device": "Approved device: {{device}}", + "msg.userfront.dashboard.approved_ip": "Approved IP: {{ip}}", + "msg.userfront.dashboard.approved_session.copy_click": + "{{label}}: {{id}}\\\\\\\\\\\\\\\\nClick to copy.", + "msg.userfront.dashboard.approved_session.copy_tap": + "{{label}}: {{id}}\\\\\\\\\\\\\\\\nTap to copy.", + "msg.userfront.dashboard.approved_session.none": "No {{label}}", + "msg.userfront.dashboard.audit_empty": "No recent sign-in activity.", + "msg.userfront.dashboard.audit_load_error": "Could not load sign-in history.", + "msg.userfront.dashboard.auth_method": "Auth method: {{method}}", "msg.userfront.dashboard.client_id": "Client ID: {{id}}", - "msg.userfront.dashboard.client_id_missing": "Client Id Missing", - "msg.userfront.dashboard.current_status": "Current Status", - "msg.userfront.dashboard.last_auth": "Last Auth", - "msg.userfront.dashboard.link_missing": "Link Missing", - "msg.userfront.dashboard.link_open_error": "Link Open Error", + "msg.userfront.dashboard.client_id_missing": "No client ID available.", + "msg.userfront.dashboard.current_status": "Current status: {{status}}", + "msg.userfront.dashboard.last_auth": "Last signed in: {{value}}", + "msg.userfront.dashboard.link_missing": + "This app does not have a launch URL configured.", + "msg.userfront.dashboard.link_open_error": "Could not open the app link.", + "msg.userfront.dashboard.link_status": "Link status: {{status}}", "msg.userfront.dashboard.render_error": "Dashboard render error: {{error}}", - "msg.userfront.dashboard.revoke.confirm": "Confirm", - "msg.userfront.dashboard.revoke.error": "Error", - "msg.userfront.dashboard.revoke.success": "Success", - "msg.userfront.dashboard.scopes.empty": "Empty", - "msg.userfront.dashboard.session_id_copied": "Session Id Copied", - "msg.userfront.dashboard.timeline.load_error": "Load Error", - "msg.userfront.error.detail_contact": "msg.userfront.error.detail_contact", - "msg.userfront.error.detail_generic": "Detail Generic", - "msg.userfront.error.detail_request": "Detail Request", - "msg.userfront.error.id": "Id", + "msg.userfront.dashboard.revoke.confirm": + "Disconnect {{app}}?\\\\\\\\\\\\\\\\nYou will need to grant access again the next time you sign in.", + "msg.userfront.dashboard.revoke.error": + "Could not disconnect the app: {{error}}", + "msg.userfront.dashboard.revoke.success": "{{app}} has been disconnected.", + "msg.userfront.dashboard.scopes.empty": "No scopes were requested.", + "msg.userfront.dashboard.session_id_copied": "Session ID copied.", + "msg.userfront.dashboard.sessions.browser": "Browser: {{value}}", + "msg.userfront.dashboard.sessions.empty": "No active sessions.", + "msg.userfront.dashboard.sessions.empty_detail": + "Devices signed in with this account will appear here.", + "msg.userfront.dashboard.sessions.error": "Could not load sessions.", + "msg.userfront.dashboard.sessions.os": "OS: {{value}}", + "msg.userfront.dashboard.sessions.recent_app": "Recent app: {{app}}", + "msg.userfront.dashboard.sessions.revoke.confirm": + "End the session for {{target}}?\nThat device will need to sign in again.", + "msg.userfront.dashboard.sessions.revoke.error": + "Could not end the session: {{error}}", + "msg.userfront.dashboard.sessions.revoke.success": + "The session has been ended.", + "msg.userfront.dashboard.sessions.session_id": "Session ID: {{id}}", + "msg.userfront.dashboard.timeline.load_error": + "Could not load sign-in history.", + "msg.userfront.error.detail_contact": + "If the problem continues, please contact your administrator.", + "msg.userfront.error.detail_generic": "Something went wrong.", + "msg.userfront.error.detail_request": + "We had trouble processing your request.", + "msg.userfront.error.id": "Error ID: {{id}}", "msg.userfront.error.ory.\"\$normalizedCode\"": "{{error}}", "msg.userfront.error.ory.access_denied": "The user denied the consent request.", @@ -1333,10 +2434,10 @@ const Map enStrings = { "The client is not authorized for this request.", "msg.userfront.error.ory.unsupported_response_type": "The response type is not supported.", - "msg.userfront.error.title": "Title", - "msg.userfront.error.title_generic": "Title Generic", - "msg.userfront.error.title_with_code": "Title With Code", - "msg.userfront.error.type": "Type", + "msg.userfront.error.title": "An error occurred during authentication.", + "msg.userfront.error.title_generic": "An error occurred.", + "msg.userfront.error.title_with_code": "Error: {{code}}", + "msg.userfront.error.type": "Error type: {{type}}", "msg.userfront.error.whitelist.\"\$normalizedCode\"": "{{error}}", "msg.userfront.error.whitelist.bad_request": "Please check your input.", "msg.userfront.error.whitelist.invalid_session": @@ -1355,138 +2456,213 @@ const Map enStrings = { "Account settings are currently unavailable.", "msg.userfront.error.whitelist.verification_required": "Additional verification is required. Please follow the instructions.", - "msg.userfront.forgot.description": "Description", - "msg.userfront.forgot.dry_send": "Dry Send", - "msg.userfront.forgot.error": "Error", - "msg.userfront.forgot.input_required": "Input Required", - "msg.userfront.forgot.sent": "Sent", - "msg.userfront.greeting": "Greeting", - "msg.userfront.login.cookie_check_failed": "Cookie Check Failed", - "msg.userfront.login.dry_send": "Dry Send", - "msg.userfront.login.link.approved": "Approved", - "msg.userfront.login.link.helper": "Sending you a login link", - "msg.userfront.login.link.missing_login_id": "Missing Login Id", - "msg.userfront.login.link.missing_phone": "Missing Phone", - "msg.userfront.login.link.resend_wait": "Resend Wait", - "msg.userfront.login.link.short_code_help": "Short Code Help", - "msg.userfront.login.link_failed": "Link Failed", - "msg.userfront.login.link_send_failed": "Link Send Failed", - "msg.userfront.login.link_sent_email": "Link Sent Email", - "msg.userfront.login.link_sent_phone": "Link Sent Phone", + "msg.userfront.forgot.description": + "Enter the email address or phone number linked to your account and we will send you a password reset link.", + "msg.userfront.forgot.dry_send": + "Dry-send mode: no email or SMS was actually sent.", + "msg.userfront.forgot.error": "Failed to send the reset link: {{error}}", + "msg.userfront.forgot.input_required": + "Enter your email address or phone number.", + "msg.userfront.forgot.sent": + "A password reset link has been sent. Check your email or SMS.", + "msg.userfront.greeting": "Hello, {{name}}.", + "msg.userfront.login.cookie_check_failed": + "Could not verify your sign-in state: {{error}}", + "msg.userfront.login.dry_send": + "Dry-send mode: no email or SMS was actually sent.", + "msg.userfront.login.link.approved": + "Sign-in approved. You will be redirected to the sign-in page shortly.", + "msg.userfront.login.link.helper": + "We will send a sign-in link using the information you enter.", + "msg.userfront.login.link.missing_login_id": + "Enter your email address or phone number.", + "msg.userfront.login.link.missing_phone": "Enter your phone number.", + "msg.userfront.login.link.resend_wait": "You can resend in {{time}}.", + "msg.userfront.login.link.short_code_help": + "You can also sign in with the last 2 letters and 6 digits from the link you received.", + "msg.userfront.login.link_failed": + "Could not complete link sign-in: {{error}}", + "msg.userfront.login.link_send_failed": + "Failed to send the sign-in link: {{error}}", + "msg.userfront.login.link_sent_email": + "We sent a sign-in link to your email address.", + "msg.userfront.login.link_sent_phone": + "We sent a sign-in link to your phone number.", "msg.userfront.login.link_timeout": "Time expired.", "msg.userfront.login.no_account": "New to Baron?", - "msg.userfront.login.oidc_failed": "OIDC Failed", - "msg.userfront.login.password.failed": "Failed", - "msg.userfront.login.password.missing_credentials": "Missing Credentials", - "msg.userfront.login.qr.load_failed": "Load Failed", - "msg.userfront.login.qr.scan_hint": "Scan Hint", + "msg.userfront.login.oidc_failed": "OIDC sign-in failed. Please try again.", + "msg.userfront.login.password.failed": "Sign-in failed: {{error}}", + "msg.userfront.login.password.missing_credentials": + "Enter both your email or phone number and your password.", + "msg.userfront.login.qr.load_failed": "Could not load the QR code.", + "msg.userfront.login.qr.scan_hint": "Scan it with the mobile app.", "msg.userfront.login.qr_expired": "Time expired.", - "msg.userfront.login.qr_init_failed": "QR Init Failed", - "msg.userfront.login.qr_login_required": "QR Login Required", - "msg.userfront.login.short_code.invalid": "Invalid", - "msg.userfront.login.token_missing": "Token Missing", - "msg.userfront.login.unregistered.body": "Body", - "msg.userfront.login.verification.approved": "Approved", - "msg.userfront.login.verification.approved_local": "Approved Local", - "msg.userfront.login.verification.success": "Success", - "msg.userfront.login.verification_failed": "Verification Failed", - "msg.userfront.login_success.subtitle": "Subtitle", - "msg.userfront.profile.department_missing": "Department Missing", - "msg.userfront.profile.department_required": "Department Required", - "msg.userfront.profile.email_missing": "Email Missing", - "msg.userfront.profile.greeting": "Greeting", - "msg.userfront.profile.load_failed": "Load Failed", - "msg.userfront.profile.name_missing": "Name Missing", - "msg.userfront.profile.name_required": "Name Required", - "msg.userfront.profile.password.change_failed": "Change Failed", - "msg.userfront.profile.password.changed": "Changed", - "msg.userfront.profile.password.current_required": "Current Required", - "msg.userfront.profile.password.mismatch": "Mismatch", - "msg.userfront.profile.password.new_required": "New Required", - "msg.userfront.profile.password.subtitle": "Subtitle", - "msg.userfront.profile.phone.code_sent": "Code Sent", - "msg.userfront.profile.phone.send_failed": "Send Failed", - "msg.userfront.profile.phone.verified": "Verified", - "msg.userfront.profile.phone.verify_failed": "Verify Failed", - "msg.userfront.profile.phone.verify_notice": "Verify Notice", - "msg.userfront.profile.phone_required": "Phone Required", - "msg.userfront.profile.phone_verify_required": "Phone Verify Required", - "msg.userfront.profile.section.basic": "Basic", - "msg.userfront.profile.section.organization": "Organization", - "msg.userfront.profile.section.security": "Security", - "msg.userfront.profile.update_failed": "Update Failed", - "msg.userfront.profile.update_success": "Update Success", - "msg.userfront.qr.approve_error": "Approve Error", - "msg.userfront.qr.approve_success": "Approve Success", - "msg.userfront.qr.camera_error": "Camera Error", - "msg.userfront.qr.permission_error": "Permission Error", - "msg.userfront.qr.permission_required": "Permission Required", + "msg.userfront.login.qr_init_failed": + "Failed to initialize QR sign-in: {{error}}", + "msg.userfront.login.qr_login_required": + "You need to be signed in to approve a QR sign-in.", + "msg.userfront.login.short_code.invalid": + "Enter the 2 letters and 6 digits from your code.", + "msg.userfront.login.token_missing": "Could not find the sign-in token.", + "msg.userfront.login.unregistered.body": + "We could not find an account for that information.\\\\\\\\\\\\\\\\nPlease sign up before continuing.", + "msg.userfront.login.verification.approved": + "Approved. Complete sign-in in the original window.", + "msg.userfront.login.verification.approved_local": + "Approved. This device is already signed in, and the remote window will be signed in shortly.", + "msg.userfront.login.verification.success": "Sign-in approval completed.", + "msg.userfront.login.verification_failed": + "Failed to approve the sign-in request: {{error}}", + "msg.userfront.login_success.subtitle": "You have signed in successfully.", + "msg.userfront.profile.department_missing": "No department information", + "msg.userfront.profile.department_required": "Enter your department.", + "msg.userfront.profile.email_missing": "No email address", + "msg.userfront.profile.greeting": "Hello, {{name}}.", + "msg.userfront.profile.load_failed": "Could not load your profile.", + "msg.userfront.profile.name_missing": "No name provided", + "msg.userfront.profile.name_required": "Enter your name.", + "msg.userfront.profile.password.change_failed": + "Failed to change your password: {{error}}", + "msg.userfront.profile.password.changed": "Your password has been changed.", + "msg.userfront.profile.password.current_required": + "Enter your current password.", + "msg.userfront.profile.password.mismatch": "The new passwords do not match.", + "msg.userfront.profile.password.new_required": "Enter a new password.", + "msg.userfront.profile.password.subtitle": + "Verify your current password before setting a new one.", + "msg.userfront.profile.phone.code_sent": "A verification code has been sent.", + "msg.userfront.profile.phone.send_failed": + "Failed to send the code: {{error}}", + "msg.userfront.profile.phone.verified": "Phone number verified.", + "msg.userfront.profile.phone.verify_failed": "Verification failed: {{error}}", + "msg.userfront.profile.phone.verify_notice": + "SMS verification is required to change your phone number.", + "msg.userfront.profile.phone_required": "Enter your phone number.", + "msg.userfront.profile.phone_verify_required": + "Phone verification is required.", + "msg.userfront.profile.section.basic": + "Manage your basic account information.", + "msg.userfront.profile.section.organization": + "Your organization and affiliation details.", + "msg.userfront.profile.section.security": "Keep your password secure.", + "msg.userfront.profile.update_failed": + "Failed to update your profile: {{error}}", + "msg.userfront.profile.update_success": "Your profile has been updated.", + "msg.userfront.qr.approve_error": "QR approval failed: {{error}}", + "msg.userfront.qr.approve_success": + "QR approval complete. Continue on your desktop.", + "msg.userfront.qr.camera_error": "Camera error: {{error}}", + "msg.userfront.qr.permission_error": + "Could not request camera access. Check your browser or OS settings.", + "msg.userfront.qr.permission_required": "Camera access is required.", "msg.userfront.reset.error.empty_password": "Please enter Password.", - "msg.userfront.reset.error.generic": "Generic", - "msg.userfront.reset.error.lowercase": "Lowercase", - "msg.userfront.reset.error.min_length": "Min Length", - "msg.userfront.reset.error.min_types": "Min Types", - "msg.userfront.reset.error.mismatch": "Mismatch", - "msg.userfront.reset.error.number": "Number", - "msg.userfront.reset.error.symbol": "Symbol", - "msg.userfront.reset.error.uppercase": "Uppercase", - "msg.userfront.reset.invalid_body": "Invalid Body", - "msg.userfront.reset.invalid_link": "Invalid Link", - "msg.userfront.reset.invalid_title": "Invalid Title", - "msg.userfront.reset.policy.lowercase": "Lowercase", - "msg.userfront.reset.policy.min_length": "Min Length", - "msg.userfront.reset.policy.min_types": "Min Types", - "msg.userfront.reset.policy.number": "Number", - "msg.userfront.reset.policy.symbol": "Symbol", - "msg.userfront.reset.policy.uppercase": "Uppercase", - "msg.userfront.reset.policy_loading": "Policy Loading", - "msg.userfront.reset.success": "Success", - "msg.userfront.sections.apps_subtitle": "Apps Subtitle", - "msg.userfront.sections.audit_subtitle": "Audit Subtitle", - "msg.userfront.settings.disabled": "Disabled", - "msg.userfront.signup.agreement.title": "Title", - "msg.userfront.signup.auth.affiliate_notice": "Affiliate Notice", - "msg.userfront.signup.auth.title": "Title", - "msg.userfront.signup.email.code_mismatch": "Code Mismatch", - "msg.userfront.signup.email.duplicate": "Duplicate", - "msg.userfront.signup.email.invalid": "Invalid", - "msg.userfront.signup.email.send_failed": "Send Failed", - "msg.userfront.signup.email.verified": "Verified", - "msg.userfront.signup.email.verify_failed": "Verify Failed", + "msg.userfront.reset.error.generic": + "Failed to change your password: {{error}}", + "msg.userfront.reset.error.lowercase": + "Include at least one lowercase letter.", + "msg.userfront.reset.error.min_length": "Use at least {{count}} characters.", + "msg.userfront.reset.error.min_types": + "Use at least {{count}} character types: uppercase, lowercase, number, or symbol.", + "msg.userfront.reset.error.mismatch": "The passwords do not match.", + "msg.userfront.reset.error.number": "Include at least one number.", + "msg.userfront.reset.error.symbol": "Include at least one symbol.", + "msg.userfront.reset.error.uppercase": + "Include at least one uppercase letter.", + "msg.userfront.reset.invalid_body": + "This password reset link is invalid or has expired. Please request a new one.", + "msg.userfront.reset.invalid_link": + "This reset link is invalid. Missing loginId or token.", + "msg.userfront.reset.invalid_title": "Invalid reset link", + "msg.userfront.reset.policy.lowercase": "At least one lowercase letter", + "msg.userfront.reset.policy.min_length": "At least {{count}} characters", + "msg.userfront.reset.policy.min_types": "At least {{count}} character types", + "msg.userfront.reset.policy.number": "At least one number", + "msg.userfront.reset.policy.symbol": "At least one symbol", + "msg.userfront.reset.policy.uppercase": "At least one uppercase letter", + "msg.userfront.reset.policy_loading": "Loading the password policy...", + "msg.userfront.reset.success": + "Your password has been changed successfully. Please sign in again.", + "msg.userfront.sections.apps_subtitle": + "Your linked apps and their latest sign-in status.", + "msg.userfront.sections.audit_subtitle": + "Recent access history for Baron sign-in.", + "msg.userfront.sections.sessions_subtitle": + "Your currently signed-in devices and browser sessions.", + "msg.userfront.settings.disabled": + "Account settings are currently unavailable.", + "msg.userfront.signup.agreement.all_hint": + "Agree to both required documents to continue to the next step.", + "msg.userfront.signup.agreement.description": + "Review the service terms and privacy collection notice, then agree to continue.", + "msg.userfront.signup.agreement.privacy_summary": + "Review what personal data is collected, why it is used, and how it is retained.", + "msg.userfront.signup.agreement.progress": + "{{count}} of {{total}} required agreements completed", + "msg.userfront.signup.agreement.title": + "Please review and agree to the terms to continue.", + "msg.userfront.signup.agreement.tos_summary": + "Review the service terms, usage conditions, and responsibilities.", + "msg.userfront.signup.auth.affiliate_notice": + "If you are an affiliate employee, use your official company email address.", + "msg.userfront.signup.auth.title": "Verify your email and phone number.", + "msg.userfront.signup.email.code_mismatch": + "The verification code does not match.", + "msg.userfront.signup.email.duplicate": + "This email address is already registered.", + "msg.userfront.signup.email.invalid": "Enter a valid email address.", + "msg.userfront.signup.email.send_failed": + "Failed to send the email: {{error}}", + "msg.userfront.signup.email.verified": "Email verified.", + "msg.userfront.signup.email.verify_failed": + "Email verification failed: {{error}}", "msg.userfront.signup.failed": "Failed", - "msg.userfront.signup.password.length_required": "Length Required", - "msg.userfront.signup.password.lowercase_required": "Lowercase Required", - "msg.userfront.signup.password.mismatch": "Mismatch", - "msg.userfront.signup.password.number_required": "Number Required", - "msg.userfront.signup.password.rule.lowercase": "Lowercase", - "msg.userfront.signup.password.rule.min_length": "Min Length", - "msg.userfront.signup.password.rule.min_types": "Min Types", + "msg.userfront.signup.password.length_required": + "Your password must be at least 12 characters long.", + "msg.userfront.signup.password.lowercase_required": + "Include at least one lowercase letter.", + "msg.userfront.signup.password.mismatch": "The passwords do not match.", + "msg.userfront.signup.password.number_required": + "Include at least one number.", + "msg.userfront.signup.password.rule.lowercase": "Lowercase letter", + "msg.userfront.signup.password.rule.min_length": + "At least {{count}} characters", + "msg.userfront.signup.password.rule.min_types": + "At least {{count}} character types", "msg.userfront.signup.password.rule.number": "Number", "msg.userfront.signup.password.rule.symbol": "Symbol", - "msg.userfront.signup.password.rule.uppercase": "Uppercase", - "msg.userfront.signup.password.symbol_required": "Symbol Required", - "msg.userfront.signup.password.title": "Title", - "msg.userfront.signup.password.uppercase_required": "Uppercase Required", - "msg.userfront.signup.phone.code_mismatch": "Code Mismatch", - "msg.userfront.signup.phone.send_failed": "Send Failed", - "msg.userfront.signup.phone.verified": "Verified", - "msg.userfront.signup.phone.verify_failed": "Verify Failed", - "msg.userfront.signup.policy.loading": "Loading", - "msg.userfront.signup.policy.lowercase": "Lowercase", - "msg.userfront.signup.policy.min_length": "Min Length", - "msg.userfront.signup.policy.min_types": "Min Types", + "msg.userfront.signup.password.rule.uppercase": "Uppercase letter", + "msg.userfront.signup.password.symbol_required": + "Include at least one symbol.", + "msg.userfront.signup.password.title": + "Create a secure password to finish signing up.", + "msg.userfront.signup.password.uppercase_required": + "Include at least one uppercase letter.", + "msg.userfront.signup.phone.code_mismatch": + "The verification code does not match.", + "msg.userfront.signup.phone.send_failed": "Failed to send the SMS: {{error}}", + "msg.userfront.signup.phone.verified": "Phone number verified.", + "msg.userfront.signup.phone.verify_failed": + "Phone verification failed: {{error}}", + "msg.userfront.signup.policy.loading": "Loading the password policy...", + "msg.userfront.signup.policy.lowercase": "Lowercase letter", + "msg.userfront.signup.policy.min_length": "At least {{count}} characters", + "msg.userfront.signup.policy.min_types": "At least {{count}} character types", "msg.userfront.signup.policy.number": "Number", - "msg.userfront.signup.policy.summary": "Summary", + "msg.userfront.signup.policy.summary": "Security policy: {{rules}}", "msg.userfront.signup.policy.symbol": "Symbol", - "msg.userfront.signup.policy.uppercase": "Uppercase", + "msg.userfront.signup.policy.uppercase": "Uppercase letter", "msg.userfront.signup.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", - "msg.userfront.signup.profile.affiliate_hint": "Affiliate Hint", - "msg.userfront.signup.profile.title": "Title", - "msg.userfront.signup.success.body": "Body", - "msg.userfront.signup.success.title": "Title", + "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\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", + "msg.userfront.signup.profile.affiliate_hint": + "This will be selected automatically when you use an affiliate email.", + "msg.userfront.signup.profile.title": "Tell us about your affiliation.", + "msg.userfront.signup.success.body": + "Your account has been created successfully.", + "msg.userfront.signup.success.title": "Sign-up complete", "msg.userfront.signup.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", + "\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\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", + "non.existent.key": "Non-existent key", + "test.key": "Test", "ui.admin.api_keys.create.name_label": "Name Label", "ui.admin.api_keys.create.name_placeholder": "Name Placeholder", "ui.admin.api_keys.create.section_name": "Section Name", @@ -1494,8 +2670,8 @@ const Map enStrings = { "ui.admin.api_keys.create.submit": "Submit", "ui.admin.api_keys.create.success.copy_secret": "Copy Secret", "ui.admin.api_keys.create.success.go_list": "Go List", - "ui.admin.api_keys.create.success.title": "Title", - "ui.admin.api_keys.create.title": "Title", + "ui.admin.api_keys.create.success.title": "API Key Created", + "ui.admin.api_keys.create.title": "Create New API Key", "ui.admin.api_keys.list.add": "Add", "ui.admin.api_keys.list.breadcrumb.list": "List", "ui.admin.api_keys.list.breadcrumb.section": "API Keys", @@ -1505,7 +2681,7 @@ const Map enStrings = { "ui.admin.api_keys.list.table.last_used": "LAST USED", "ui.admin.api_keys.list.table.name": "NAME", "ui.admin.api_keys.list.table.scopes": "SCOPES", - "ui.admin.api_keys.list.title": "Title", + "ui.admin.api_keys.list.title": "API Key Management", "ui.admin.audit.breadcrumb.logs": "Logs", "ui.admin.audit.breadcrumb.section": "Audit", "ui.admin.audit.copy.actor_id": "Copy actor id", @@ -1536,12 +2712,13 @@ const Map enStrings = { "ui.admin.audit.table.status": "STATUS", "ui.admin.audit.table.time": "TIME", "ui.admin.audit.target": "Target · {{target}}", - "ui.admin.audit.title": "Title", + "ui.admin.audit.title": "Audit Logs", "ui.admin.brand": "Brand", "ui.admin.dev_role_switcher": "🛠 DEV Role Switcher", - "ui.admin.groups.add_unit": "Organization Add", - "ui.admin.groups.create.description": "Description", - "ui.admin.groups.create.title": "Title", + "ui.admin.dev_role_switcher_real": "Use real role", + "ui.admin.groups.create.description": + "Adds a new organization unit such as a department or team.", + "ui.admin.groups.create.title": "Create Organization Unit", "ui.admin.groups.detail.breadcrumb_org": "Breadcrumb Org", "ui.admin.groups.detail.breadcrumb_tenant": "Tenant Details", "ui.admin.groups.detail.breadcrumb_unit": "Breadcrumb Unit", @@ -1549,12 +2726,11 @@ const Map enStrings = { "ui.admin.groups.detail.members_title": "Members Title", "ui.admin.groups.detail.permissions_subtitle": "Permissions Subtitle", "ui.admin.groups.detail.permissions_title": "Permission Manage", - "ui.admin.groups.form.desc_label": "Description", + "ui.admin.groups.form.desc_label": "Description Label", "ui.admin.groups.form.desc_placeholder": "Desc Placeholder", "ui.admin.groups.form.name_label": "Group Name", "ui.admin.groups.form.name_placeholder": "Name Placeholder", - "ui.admin.groups.form.parent_label": "Parent Label", - "ui.admin.groups.form.parent_none": "Parent None", + "ui.admin.groups.form.parent_label": "Parent Unit", "ui.admin.groups.form.submit": "Submit", "ui.admin.groups.form.unit_level_label": "Unit Level Label", "ui.admin.groups.form.unit_level_placeholder": "Unit Level Placeholder", @@ -1564,11 +2740,10 @@ const Map enStrings = { "ui.admin.groups.members.table.name": "Name", "ui.admin.groups.members.table.remove": "Remove", "ui.admin.groups.table.actions": "ACTIONS", - "ui.admin.groups.table.created_at": "Created At", - "ui.admin.groups.table.level": "Level", "ui.admin.groups.table.members": "MEMBERS", "ui.admin.groups.table.name": "NAME", "ui.admin.header.plane": "Admin Plane", + "ui.admin.header.subtitle": "Manage tenants, policies, and operators", "ui.admin.nav.api_keys": "API Keys", "ui.admin.nav.audit_logs": "Audit Logs", "ui.admin.nav.auth_guard": "Auth Guard", @@ -1579,18 +2754,28 @@ const Map enStrings = { "ui.admin.nav.tenants": "Tenants", "ui.admin.nav.user_groups": "User Groups", "ui.admin.nav.users": "Users", + "ui.admin.org.download_template": "Download Template", + "ui.admin.org.import_btn": "Import", + "ui.admin.org.import_title": "Bulk Organization Import", + "ui.admin.org.start_import": "Start Import", "ui.admin.overview.kicker": "Global Overview", "ui.admin.overview.playbook.title": "Admin playbook", "ui.admin.overview.quick_links.add_tenant": "Tenant Add", - "ui.admin.overview.quick_links.tenant_dashboard": "Tenant Dashboard", - "ui.admin.overview.quick_links.title": "Title", + "ui.admin.overview.quick_links.api_key_management": "API Key Management", + "ui.admin.overview.quick_links.title": "Quick Links", + "ui.admin.overview.quick_links.user_management": "User Management", "ui.admin.overview.quick_links.view_audit_logs": "View Audit Logs", + "ui.admin.overview.summary.audit_events_24h": "24h Events", + "ui.admin.overview.summary.oidc_clients": "OIDC Clients", + "ui.admin.overview.summary.policy_gate": "Policy Gate", + "ui.admin.overview.summary.total_tenants": "Total Tenants", "ui.admin.overview.title": "Tenant-independent control plane", + "ui.admin.profile.manageable_tenants": "Manageable Tenants", "ui.admin.role.rp_admin": "RP ADMIN", "ui.admin.role.super_admin": "SUPER ADMIN", "ui.admin.role.tenant_admin": "TENANT ADMIN", - "ui.admin.role.tenant_member": "TENANT MEMBER", - "ui.admin.tenants.add": "Tenant Add", + "ui.admin.role.user": "TENANT MEMBER", + "ui.admin.tenants.add": "Add Tenant", "ui.admin.tenants.admins.add_button": "Add Button", "ui.admin.tenants.admins.already_admin": "Already Admin", "ui.admin.tenants.admins.dialog_description": "Dialog Description", @@ -1603,88 +2788,143 @@ const Map enStrings = { "ui.admin.tenants.admins.table_actions": "Table Actions", "ui.admin.tenants.admins.table_email": "Email", "ui.admin.tenants.admins.table_name": "Name", - "ui.admin.tenants.admins.title": "Title", + "ui.admin.tenants.admins.title": "Tenant Admins", "ui.admin.tenants.breadcrumb.list": "List", "ui.admin.tenants.breadcrumb.section": "Tenants", "ui.admin.tenants.create.breadcrumb.action": "Create", "ui.admin.tenants.create.breadcrumb.section": "Tenants", - "ui.admin.tenants.create.form.description": "Description", + "ui.admin.tenants.create.form.description": "Tenant Description", "ui.admin.tenants.create.form.domains_label": "Allowed Domains (Comma separated)", "ui.admin.tenants.create.form.domains_placeholder": "example.com, example.kr", "ui.admin.tenants.create.form.name": "Tenant name", + "ui.admin.tenants.create.form.name_placeholder": "Enter tenant name", "ui.admin.tenants.create.form.parent": "Parent", "ui.admin.tenants.create.form.slug": "Slug", "ui.admin.tenants.create.form.slug_placeholder": "tenant-slug", "ui.admin.tenants.create.form.status": "Status", "ui.admin.tenants.create.form.type": "Type", - "ui.admin.tenants.create.memo.title": "Title", + "ui.admin.tenants.create.memo.title": "Policy Memo", "ui.admin.tenants.create.profile.title": "Tenant Profile", "ui.admin.tenants.create.title": "Tenant Add", "ui.admin.tenants.detail.breadcrumb_list": "Tenant List", "ui.admin.tenants.detail.header_subtitle": "Header Subtitle", - "ui.admin.tenants.detail.loading": "Loading", - "ui.admin.tenants.detail.tab_admins": "Tab Admins", + "ui.admin.tenants.detail.loading": "Loading tenant details...", "ui.admin.tenants.detail.tab_federation": "Tab Federation", "ui.admin.tenants.detail.tab_organization": "Organization Manage", + "ui.admin.tenants.detail.tab_permissions": "Permissions", "ui.admin.tenants.detail.tab_profile": "Profile", "ui.admin.tenants.detail.tab_schema": "Tab Schema", "ui.admin.tenants.detail.title": "Details", "ui.admin.tenants.list.select_placeholder": "Select Placeholder", + "ui.admin.tenants.members.descendants": "Descendant Members", + "ui.admin.tenants.members.direct": "Direct Members", + "ui.admin.tenants.members.direct_label": "Direct", + "ui.admin.tenants.members.list_title": "Member Management", "ui.admin.tenants.members.table.email": "EMAIL", "ui.admin.tenants.members.table.name": "NAME", "ui.admin.tenants.members.table.role": "ROLE", "ui.admin.tenants.members.table.status": "STATUS", "ui.admin.tenants.members.title": "Tenant Members ({{count}})", + "ui.admin.tenants.members.total": "Total", + "ui.admin.tenants.members.total_label": "Total", + "ui.admin.tenants.owners.add_button": "Add Owner", + "ui.admin.tenants.owners.already_owner": "Already Owner", + "ui.admin.tenants.owners.dialog_description": + "Search users by name or email.", + "ui.admin.tenants.owners.dialog_title": "Add New Owner", + "ui.admin.tenants.owners.remove_title": "Revoke Owner Permission", + "ui.admin.tenants.owners.table_actions": "Actions", + "ui.admin.tenants.owners.table_email": "Email", + "ui.admin.tenants.owners.table_name": "Name", + "ui.admin.tenants.owners.title": "Tenant Owners", "ui.admin.tenants.profile.allowed_domains": "Allowed Domains", - "ui.admin.tenants.profile.allowed_domains_help": "Allowed Domains Help", - "ui.admin.tenants.profile.approve_button": "Tenant Approve", - "ui.admin.tenants.profile.description": "Description", + "ui.admin.tenants.profile.allowed_domains_help": + "Users with these email domains will be automatically assigned to this tenant.", + "ui.admin.tenants.profile.approve_button": "Approve Tenant", + "ui.admin.tenants.profile.description": + "Review and edit the tenant's basic profile information.", + "ui.admin.tenants.profile.form.parent": "Parent Tenant (Optional)", + "ui.admin.tenants.profile.form.parent_help": + "Select a parent tenant if this is a subsidiary or sub-organization.", "ui.admin.tenants.profile.name": "Tenant Name", "ui.admin.tenants.profile.slug": "Slug", "ui.admin.tenants.profile.status": "Status", - "ui.admin.tenants.profile.subtitle": "Subtitle", + "ui.admin.tenants.profile.subtitle": + "Slug and status changes are applied immediately.", "ui.admin.tenants.profile.title": "Tenant Profile", "ui.admin.tenants.profile.type": "Type", "ui.admin.tenants.registry.title": "Tenant registry", "ui.admin.tenants.schema.add_field": "Add Field", + "ui.admin.tenants.schema.field.admin_only": "Admin Only", + "ui.admin.tenants.schema.field.is_login_id": "Is Login Id", "ui.admin.tenants.schema.field.key": "Field Key (ID)", "ui.admin.tenants.schema.field.key_placeholder": "e.g. employee_id", "ui.admin.tenants.schema.field.label": "Display Label", "ui.admin.tenants.schema.field.label_placeholder": "Label Placeholder", + "ui.admin.tenants.schema.field.required": "Required", "ui.admin.tenants.schema.field.type": "Type", "ui.admin.tenants.schema.field.type_boolean": "Boolean", + "ui.admin.tenants.schema.field.type_date": "Date", + "ui.admin.tenants.schema.field.type_datetime": "DateTime", + "ui.admin.tenants.schema.field.type_float": "Float", "ui.admin.tenants.schema.field.type_number": "Number", - "ui.admin.tenants.schema.field.type_text": "Text", - "ui.admin.tenants.schema.save": "Save Schema Changes", + "ui.admin.tenants.schema.field.type_text": "Text Value", + "ui.admin.tenants.schema.field.unsigned": "Unsigned", + "ui.admin.tenants.schema.field.validation_placeholder": + "Regex Pattern (Optional)", + "ui.admin.tenants.schema.save": "Save Schema", "ui.admin.tenants.schema.title": "User Schema Extension", "ui.admin.tenants.sub.add": "Add", + "ui.admin.tenants.sub.add_dialog_desc": + "Select a tenant to add as a sub-tenant.", + "ui.admin.tenants.sub.add_dialog_title": "Add Sub-tenant", + "ui.admin.tenants.sub.add_existing": "Add Existing Tenant", "ui.admin.tenants.sub.manage": "Manage", + "ui.admin.tenants.sub.no_candidates": "No available tenants to add.", + "ui.admin.tenants.sub.search_placeholder": "Search...", "ui.admin.tenants.sub.table.action": "ACTION", "ui.admin.tenants.sub.table.name": "NAME", "ui.admin.tenants.sub.table.slug": "SLUG", "ui.admin.tenants.sub.table.status": "STATUS", "ui.admin.tenants.sub.title": "Sub-tenants ({{count}})", + "ui.admin.tenants.sub.tree_search_placeholder": "Search in tree...", "ui.admin.tenants.table.actions": "ACTIONS", + "ui.admin.tenants.table.members": "Members", "ui.admin.tenants.table.name": "NAME", "ui.admin.tenants.table.slug": "SLUG", "ui.admin.tenants.table.status": "STATUS", "ui.admin.tenants.table.type": "TYPE", "ui.admin.tenants.table.updated": "UPDATED", - "ui.admin.tenants.title": "Tenant List", + "ui.admin.tenants.title": "Tenant Registry", "ui.admin.title": "Admin Control", - "ui.admin.users.create.account.title": "Title", + "ui.admin.users.bulk.acknowledge_warning": + "I acknowledge the warning and will proceed.", + "ui.admin.users.bulk.do_move": "Execute Move", + "ui.admin.users.bulk.download_template": "Download Template", + "ui.admin.users.bulk.move_group": "Bulk Tenant Move", + "ui.admin.users.bulk.move_title": "Bulk User Move", + "ui.admin.users.bulk.no_department": "No Department", + "ui.admin.users.bulk.schema_warning": "Schema Compatibility Warning", + "ui.admin.users.bulk.select_group": "Select Target Tenant", + "ui.admin.users.bulk.selected_count": "{{count}} users selected", + "ui.admin.users.bulk.start_upload": "Start Upload", + "ui.admin.users.bulk.title": "Bulk Actions", + "ui.admin.users.create.account.title": "Account Information", "ui.admin.users.create.back": "Back", "ui.admin.users.create.breadcrumb.new": "New", "ui.admin.users.create.breadcrumb.section": "Users", - "ui.admin.users.create.custom_fields.title": "Title", + "ui.admin.users.create.custom_fields.title": "Tenant Custom Fields", "ui.admin.users.create.form.auto_password": "Auto Password", "ui.admin.users.create.form.department": "Department", "ui.admin.users.create.form.department_placeholder": "Department Placeholder", "ui.admin.users.create.form.email": "Email", "ui.admin.users.create.form.email_placeholder": "user@example.com", + "ui.admin.users.create.form.is_login_id": "Sign in ID", "ui.admin.users.create.form.job_title": "Job Title", - "ui.admin.users.create.form.job_title_placeholder": "Job Title Placeholder", + "ui.admin.users.create.form.job_title_placeholder": "e.g. Frontend Developer", + "ui.admin.users.create.form.login_id": "Sign in ID (Optional)", + "ui.admin.users.create.form.login_id_placeholder": "Login Id Placeholder", "ui.admin.users.create.form.name": "Name", "ui.admin.users.create.form.name_placeholder": "Name Placeholder", "ui.admin.users.create.form.password": "Password", @@ -1692,60 +2932,461 @@ const Map enStrings = { "ui.admin.users.create.form.phone": "Phone number", "ui.admin.users.create.form.phone_placeholder": "010-1234-5678", "ui.admin.users.create.form.position": "Position", - "ui.admin.users.create.form.position_placeholder": "Position Placeholder", + "ui.admin.users.create.form.position_placeholder": "e.g. Senior", "ui.admin.users.create.form.role": "Role", - "ui.admin.users.create.form.tenant": "Tenant (Tenant)", + "ui.admin.users.create.form.tenant": "Tenant", "ui.admin.users.create.form.tenant_global": "Tenant Global", "ui.admin.users.create.go_list": "Go List", - "ui.admin.users.create.password_generated.title": "Title", + "ui.admin.users.create.password_generated.title": + "Initial Password Generated", "ui.admin.users.create.submit": "User Create", "ui.admin.users.create.title": "User Add", "ui.admin.users.detail.back": "Back", "ui.admin.users.detail.breadcrumb.section": "Users", - "ui.admin.users.detail.custom_fields.title": "Title", + "ui.admin.users.detail.contact_title": "ui.admin.users.detail.contact_title", + "ui.admin.users.detail.created_at": "Created At", + "ui.admin.users.detail.custom_fields.multi_title": + "Per-tenant Profile Management", + "ui.admin.users.detail.delete": "User Delete", "ui.admin.users.detail.edit_title": "Edit Title", + "ui.admin.users.detail.form.- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache": + "ui.admin.users.detail.form.- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache", "ui.admin.users.detail.form.department": "Department", "ui.admin.users.detail.form.department_placeholder": "Department Placeholder", + "ui.admin.users.detail.form.email": "Email", + "ui.admin.users.detail.form.is_login_id": "Sign in ID", "ui.admin.users.detail.form.job_title": "Job Title", - "ui.admin.users.detail.form.job_title_placeholder": "Job Title Placeholder", + "ui.admin.users.detail.form.job_title_placeholder": + "ui.admin.users.detail.form.job_title_placeholder", + "ui.admin.users.detail.form.login_id": "Sign in ID", + "ui.admin.users.detail.form.login_id_placeholder": "Login Id Placeholder", + "ui.admin.users.detail.form.msg.admin.users.detail.history_desc": + "ui.admin.users.detail.form.msg.admin.users.detail.history_desc", + "ui.admin.users.detail.form.msg.admin.users.detail.no_history": + "ui.admin.users.detail.form.msg.admin.users.detail.no_history", + "ui.admin.users.detail.form.msg.admin.users.detail.no_tenants": + "ui.admin.users.detail.form.msg.admin.users.detail.no_tenants", + "ui.admin.users.detail.form.msg.admin.users.detail.reset_auto_desc": + "ui.admin.users.detail.form.msg.admin.users.detail.reset_auto_desc", + "ui.admin.users.detail.form.msg.admin.users.detail.security_desc": + "ui.admin.users.detail.form.msg.admin.users.detail.security_desc", + "ui.admin.users.detail.form.msg.admin.users.detail.tenant_slug_help": + "ui.admin.users.detail.form.msg.admin.users.detail.tenant_slug_help", + "ui.admin.users.detail.form.msg.admin.users.detail.tenants_desc": + "ui.admin.users.detail.form.msg.admin.users.detail.tenants_desc", + "ui.admin.users.detail.form.msg.common.copied": + "ui.admin.users.detail.form.msg.common.copied", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.allowed_algorithms_tooltip": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.allowed_algorithms_tooltip", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithm_badge": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithm_badge", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithm_reason": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithm_reason", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithms_help": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithms_help", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithms_title": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.missing_algorithms_title", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.parsed_keys_empty": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.parsed_keys_empty", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.parsed_keys_help": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.parsed_keys_help", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_empty": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_empty", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_help": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_help", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_refresh_failed": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_refresh_failed", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_refreshed": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_refreshed", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoke_confirm": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoke_confirm", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoke_failed": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoke_failed", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoked": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.cache_revoked", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms", + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms": + "ui.admin.users.detail.form.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", "ui.admin.users.detail.form.name": "Name", "ui.admin.users.detail.form.name_placeholder": "Name Placeholder", "ui.admin.users.detail.form.phone": "Phone number", "ui.admin.users.detail.form.phone_placeholder": "010-1234-5678", "ui.admin.users.detail.form.position": "Position", - "ui.admin.users.detail.form.position_placeholder": "Position Placeholder", + "ui.admin.users.detail.form.position_placeholder": + "ui.admin.users.detail.form.position_placeholder", "ui.admin.users.detail.form.role": "Role", + "ui.admin.users.detail.form.role_rp_admin": "Role Rp Admin", + "ui.admin.users.detail.form.role_super_admin": "Role Super Admin", + "ui.admin.users.detail.form.role_tenant_admin": "Role Tenant Admin", + "ui.admin.users.detail.form.role_user": "User", "ui.admin.users.detail.form.status": "Status", - "ui.admin.users.detail.form.tenant": "Tenant (Tenant)", + "ui.admin.users.detail.form.status_active": + "ui.admin.users.detail.form.status_active", + "ui.admin.users.detail.form.status_inactive": + "ui.admin.users.detail.form.status_inactive", + "ui.admin.users.detail.form.tenant": "Representative Affiliated Tenant", "ui.admin.users.detail.form.tenant_global": "Tenant Global", + "ui.admin.users.detail.form.tenant_slug": "Tenant Slug", + "ui.admin.users.detail.form.ui.admin.users.create.form.is_login_id": + "ui.admin.users.detail.form.ui.admin.users.create.form.is_login_id", + "ui.admin.users.detail.form.ui.admin.users.detail.form.email": + "ui.admin.users.detail.form.ui.admin.users.detail.form.email", + "ui.admin.users.detail.form.ui.admin.users.detail.form.is_login_id": + "ui.admin.users.detail.form.ui.admin.users.detail.form.is_login_id", + "ui.admin.users.detail.form.ui.admin.users.detail.form.role_rp_admin": + "ui.admin.users.detail.form.ui.admin.users.detail.form.role_rp_admin", + "ui.admin.users.detail.form.ui.admin.users.detail.form.tenant_slug": + "ui.admin.users.detail.form.ui.admin.users.detail.form.tenant_slug", + "ui.admin.users.detail.form.ui.admin.users.detail.generate_button": + "ui.admin.users.detail.form.ui.admin.users.detail.generate_button", + "ui.admin.users.detail.form.ui.admin.users.detail.history_title": + "ui.admin.users.detail.form.ui.admin.users.detail.history_title", + "ui.admin.users.detail.form.ui.admin.users.detail.manual_confirm": + "ui.admin.users.detail.form.ui.admin.users.detail.manual_confirm", + "ui.admin.users.detail.form.ui.admin.users.detail.manual_password": + "ui.admin.users.detail.form.ui.admin.users.detail.manual_password", + "ui.admin.users.detail.form.ui.admin.users.detail.password_done": + "ui.admin.users.detail.form.ui.admin.users.detail.password_done", + "ui.admin.users.detail.form.ui.admin.users.detail.reset_auto": + "ui.admin.users.detail.form.ui.admin.users.detail.reset_auto", + "ui.admin.users.detail.form.ui.admin.users.detail.reset_execute": + "ui.admin.users.detail.form.ui.admin.users.detail.reset_execute", + "ui.admin.users.detail.form.ui.admin.users.detail.reset_manual": + "ui.admin.users.detail.form.ui.admin.users.detail.reset_manual", + "ui.admin.users.detail.form.ui.admin.users.detail.save_tenants": + "ui.admin.users.detail.form.ui.admin.users.detail.save_tenants", + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.info": + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.info", + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.security": + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.security", + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.tenants": + "ui.admin.users.detail.form.ui.admin.users.detail.tabs.tenants", + "ui.admin.users.detail.form.ui.admin.users.detail.updated_at": + "ui.admin.users.detail.form.ui.admin.users.detail.updated_at", + "ui.admin.users.detail.form.ui.common.generate": + "ui.admin.users.detail.form.ui.common.generate", + "ui.admin.users.detail.form.ui.common.status.blocked": + "ui.admin.users.detail.form.ui.common.status.blocked", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.allowed_algorithms_info": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.allowed_algorithms_info", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_client_secret_basic": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_client_secret_basic", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_none": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_none", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_private_key_jwt": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.auth_method_private_key_jwt", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.cached_at": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.cached_at", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.error": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.error", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.expires_at": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.expires_at", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.failures": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.failures", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.kids": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.kids", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.last_checked_at": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.last_checked_at", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.last_success": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.last_success", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.parsed_key_n": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.parsed_key_n", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.parsed_keys": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.parsed_keys", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.status": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.status", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.title": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.title", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.uri": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.cache.uri", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.guide_toggle": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.guide_toggle", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.headless_disabled": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.headless_disabled", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.headless_enabled": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.headless_enabled", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.jwks_inline": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.jwks_inline", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.jwks_inline_placeholder": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.jwks_inline_placeholder", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.request_object_alg": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.request_object_alg", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.request_object_alg_placeholder": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.request_object_alg_placeholder", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.revoke_cache": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.revoke_cache", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.source": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.source", + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.source_uri": + "ui.admin.users.detail.form.ui.dev.clients.general.public_key.source_uri", + "ui.admin.users.detail.form.ui.dev.clients.general.security.trusted_rp_enable": + "ui.admin.users.detail.form.ui.dev.clients.general.security.trusted_rp_enable", + "ui.admin.users.detail.form.ui.dev.clients.general.security.trusted_rp_enable_help": + "ui.admin.users.detail.form.ui.dev.clients.general.security.trusted_rp_enable_help", + "ui.admin.users.detail.form.ui.dev.clients.help.docs_body": + "ui.admin.users.detail.form.ui.dev.clients.help.docs_body", + "ui.admin.users.detail.form.ui.dev.clients.help.subtitle": + "ui.admin.users.detail.form.ui.dev.clients.help.subtitle", + "ui.admin.users.detail.form.ui.dev.clients.registry.description": + "ui.admin.users.detail.form.ui.dev.clients.registry.description", + "ui.admin.users.detail.form.ui.dev.clients.scopes.email": + "ui.admin.users.detail.form.ui.dev.clients.scopes.email", + "ui.admin.users.detail.form.ui.dev.clients.scopes.openid": + "ui.admin.users.detail.form.ui.dev.clients.scopes.openid", + "ui.admin.users.detail.form.ui.dev.clients.scopes.profile": + "ui.admin.users.detail.form.ui.dev.clients.scopes.profile", + "ui.admin.users.detail.form.ui.dev.session.refresh": + "ui.admin.users.detail.form.ui.dev.session.refresh", + "ui.admin.users.detail.form.ui.dev.session.refreshing": + "ui.admin.users.detail.form.ui.dev.session.refreshing", + "ui.admin.users.detail.generate_button": "Generate Button", + "ui.admin.users.detail.generate_password": "Auto Generate", + "ui.admin.users.detail.go_list": "Go List", + "ui.admin.users.detail.history_title": "History Title", + "ui.admin.users.detail.manual_confirm": "Password Confirm", + "ui.admin.users.detail.manual_password": "Manual Password", + "ui.admin.users.detail.password_done": "Password Done", + "ui.admin.users.detail.password_mode_generated": "Auto Generate", + "ui.admin.users.detail.password_mode_manual": "Manual Entry", + "ui.admin.users.detail.password_result_title": "Reset Password", + "ui.admin.users.detail.password_title": "Password Manage", + "ui.admin.users.detail.reset_auto": "Reset Auto", + "ui.admin.users.detail.reset_execute": "Reset Complete", + "ui.admin.users.detail.reset_manual": "Reset Manual", + "ui.admin.users.detail.reset_password": "Reset Password", + "ui.admin.users.detail.reset_password_apply": "Apply Password", + "ui.admin.users.detail.reset_password_label": "Reset Password Label", + "ui.admin.users.detail.save": "Save", + "ui.admin.users.detail.save_tenants": "Save Tenants", "ui.admin.users.detail.security.password": "Password", "ui.admin.users.detail.security.password_placeholder": "Password Placeholder", "ui.admin.users.detail.security.title": "Security Settings", + "ui.admin.users.detail.status_title": "ui.admin.users.detail.status_title", + "ui.admin.users.detail.tabs.info": "Info", + "ui.admin.users.detail.tabs.security": "Security", + "ui.admin.users.detail.tabs.tenants": "Tenant Profile", + "ui.admin.users.detail.tenants_section.additional": + "Additional Affiliated/Manageable Tenants", + "ui.admin.users.detail.tenants_section.primary": + "Representative Affiliated Tenant", + "ui.admin.users.detail.tenants_section.title": + "Affiliation & Organization Info", "ui.admin.users.detail.title": "User Details", + "ui.admin.users.detail.toggle_password_visibility": + "Toggle password visibility", + "ui.admin.users.detail.updated_at": "Updated At", "ui.admin.users.list.add": "User Add", "ui.admin.users.list.breadcrumb.list": "List", "ui.admin.users.list.breadcrumb.section": "Users", - "ui.admin.users.list.delete_aria": "User Delete: {{name}}", - "ui.admin.users.list.edit_aria": "User Edit: {{name}}", + "ui.admin.users.list.bulk_import": "Bulk Import", + "ui.admin.users.list.columns.title": "Column Settings", + "ui.admin.users.list.empty": "No users found.", + "ui.admin.users.list.fetch_error": "Failed to load the user list.", + "ui.admin.users.list.filter.tenant": "Tenant Filter", + "ui.admin.users.list.registry.count": "{{count}} users loaded.", "ui.admin.users.list.registry.title": "User Registry", "ui.admin.users.list.search_placeholder": "Search Placeholder", + "ui.admin.users.list.subtitle": "Browse and manage registered users.", "ui.admin.users.list.table.actions": "ACTIONS", "ui.admin.users.list.table.created": "CREATED", + "ui.admin.users.list.table.login_id": "LOGIN ID", + "ui.admin.users.list.table.msg.admin.users.detail.history_desc": + "ui.admin.users.list.table.msg.admin.users.detail.history_desc", + "ui.admin.users.list.table.msg.admin.users.detail.no_history": + "ui.admin.users.list.table.msg.admin.users.detail.no_history", + "ui.admin.users.list.table.msg.admin.users.detail.no_tenants": + "ui.admin.users.list.table.msg.admin.users.detail.no_tenants", + "ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc": + "ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc", + "ui.admin.users.list.table.msg.admin.users.detail.security_desc": + "ui.admin.users.list.table.msg.admin.users.detail.security_desc", + "ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help": + "ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help", + "ui.admin.users.list.table.msg.admin.users.detail.tenants_desc": + "ui.admin.users.list.table.msg.admin.users.detail.tenants_desc", + "ui.admin.users.list.table.msg.common.copied": + "ui.admin.users.list.table.msg.common.copied", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms", + "ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms": + "ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", "ui.admin.users.list.table.name_email": "NAME / EMAIL", - "ui.admin.users.list.table.position_job": "POSITION / JOB", "ui.admin.users.list.table.role": "ROLE", "ui.admin.users.list.table.status": "STATUS", "ui.admin.users.list.table.tenant_dept": "TENANT / DEPT", - "ui.admin.users.list.tenant_slug": "Slug: {{slug}}", + "ui.admin.users.list.table.ui.admin.users.create.form.is_login_id": + "ui.admin.users.list.table.ui.admin.users.create.form.is_login_id", + "ui.admin.users.list.table.ui.admin.users.detail.form.email": + "ui.admin.users.list.table.ui.admin.users.detail.form.email", + "ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id": + "ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id", + "ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin": + "ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin", + "ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug": + "ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug", + "ui.admin.users.list.table.ui.admin.users.detail.generate_button": + "ui.admin.users.list.table.ui.admin.users.detail.generate_button", + "ui.admin.users.list.table.ui.admin.users.detail.history_title": + "ui.admin.users.list.table.ui.admin.users.detail.history_title", + "ui.admin.users.list.table.ui.admin.users.detail.manual_confirm": + "ui.admin.users.list.table.ui.admin.users.detail.manual_confirm", + "ui.admin.users.list.table.ui.admin.users.detail.manual_password": + "ui.admin.users.list.table.ui.admin.users.detail.manual_password", + "ui.admin.users.list.table.ui.admin.users.detail.password_done": + "ui.admin.users.list.table.ui.admin.users.detail.password_done", + "ui.admin.users.list.table.ui.admin.users.detail.reset_auto": + "ui.admin.users.list.table.ui.admin.users.detail.reset_auto", + "ui.admin.users.list.table.ui.admin.users.detail.reset_execute": + "ui.admin.users.list.table.ui.admin.users.detail.reset_execute", + "ui.admin.users.list.table.ui.admin.users.detail.reset_manual": + "ui.admin.users.list.table.ui.admin.users.detail.reset_manual", + "ui.admin.users.list.table.ui.admin.users.detail.save_tenants": + "ui.admin.users.list.table.ui.admin.users.detail.save_tenants", + "ui.admin.users.list.table.ui.admin.users.detail.tabs.info": + "ui.admin.users.list.table.ui.admin.users.detail.tabs.info", + "ui.admin.users.list.table.ui.admin.users.detail.tabs.security": + "ui.admin.users.list.table.ui.admin.users.detail.tabs.security", + "ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants": + "ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants", + "ui.admin.users.list.table.ui.admin.users.detail.updated_at": + "ui.admin.users.list.table.ui.admin.users.detail.updated_at", + "ui.admin.users.list.table.ui.common.generate": + "ui.admin.users.list.table.ui.common.generate", + "ui.admin.users.list.table.ui.common.status.blocked": + "ui.admin.users.list.table.ui.common.status.blocked", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.source": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.source", + "ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri": + "ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri", + "ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable": + "ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable", + "ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help": + "ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help", + "ui.admin.users.list.table.ui.dev.clients.help.docs_body": + "ui.admin.users.list.table.ui.dev.clients.help.docs_body", + "ui.admin.users.list.table.ui.dev.clients.help.subtitle": + "ui.admin.users.list.table.ui.dev.clients.help.subtitle", + "ui.admin.users.list.table.ui.dev.clients.registry.description": + "ui.admin.users.list.table.ui.dev.clients.registry.description", + "ui.admin.users.list.table.ui.dev.clients.scopes.email": + "ui.admin.users.list.table.ui.dev.clients.scopes.email", + "ui.admin.users.list.table.ui.dev.clients.scopes.openid": + "ui.admin.users.list.table.ui.dev.clients.scopes.openid", + "ui.admin.users.list.table.ui.dev.clients.scopes.profile": + "ui.admin.users.list.table.ui.dev.clients.scopes.profile", + "ui.admin.users.list.table.ui.dev.session.refresh": + "ui.admin.users.list.table.ui.dev.session.refresh", + "ui.admin.users.list.table.ui.dev.session.refreshing": + "ui.admin.users.list.table.ui.dev.session.refreshing", "ui.admin.users.list.title": "User Manage", + "ui.admin.users.table.email": "Email", + "ui.admin.users.table.name": "Name", + "ui.admin.users.table.role": "Role", "ui.common.add": "Add", "ui.common.admin_only": "Admin Only", + "ui.common.all": "All", "ui.common.assign": "Assign", "ui.common.back": "Back", + "ui.common.back_to_login": "Back to login", "ui.common.badge.admin_only": "Admin only", "ui.common.badge.command_only": "Command only", "ui.common.badge.system": "System", "ui.common.cancel": "Cancel", + "ui.common.change_file": "Change File", + "ui.common.clear_search": "Clear Search", "ui.common.close": "Close", "ui.common.collapse": "Collapse", "ui.common.confirm": "Confirm", @@ -1754,10 +3395,16 @@ const Map enStrings = { "ui.common.delete": "Delete", "ui.common.details": "Details", "ui.common.edit": "Edit", + "ui.common.enabled": "Enabled", + "ui.common.export": "Export", + "ui.common.fail": "Fail", + "ui.common.generate": "ui.common.generate", + "ui.common.go_home": "Go Home", "ui.common.hyphen": "-", "ui.common.language": "Language", "ui.common.language_en": "English", - "ui.common.language_ko": "Language Ko", + "ui.common.language_ko": "Korean", + "ui.common.manage": "Manage", "ui.common.na": "N/A", "ui.common.never": "Never", "ui.common.next": "Next", @@ -1768,28 +3415,40 @@ const Map enStrings = { "ui.common.qr": "QR", "ui.common.read_only": "Read Only", "ui.common.refresh": "Refresh", - "ui.common.requesting": "Requesting", + "ui.common.remove": "Remove", "ui.common.resend": "Resend", + "ui.common.reset": "Reset", "ui.common.retry": "Retry", - "ui.common.role.admin": "Admin", - "ui.common.role.user": "User", "ui.common.save": "Save", "ui.common.search": "Search", - "ui.common.select": "User Optional", + "ui.common.select": "Select", + "ui.common.select_file": "Select File", "ui.common.select_placeholder": "Select Placeholder", "ui.common.show_more": "Show More", "ui.common.status.active": "Active", - "ui.common.status.blocked": "Blocked", + "ui.common.status.blocked": "ui.common.status.blocked", "ui.common.status.failure": "Failure", "ui.common.status.inactive": "Inactive", "ui.common.status.ok": "Ok", "ui.common.status.pending": "Pending", "ui.common.status.success": "Success", + "ui.common.success": "Success", "ui.common.theme_dark": "Dark", "ui.common.theme_light": "Light", "ui.common.theme_toggle": "Theme Toggle", "ui.common.unknown": "Unknown", "ui.common.view": "View", + "ui.dev.audit.filter.action": "Filter by Action (e.g. ROTATE_SECRET)", + "ui.dev.audit.filter.client_id": "Filter by Client ID", + "ui.dev.audit.filter.status_all": "All Status", + "ui.dev.audit.load_more": "Load more", + "ui.dev.audit.registry.title": "Audit registry", + "ui.dev.audit.table.action": "Action", + "ui.dev.audit.table.actor": "Actor", + "ui.dev.audit.table.status": "Status", + "ui.dev.audit.table.target": "Target", + "ui.dev.audit.table.time": "Time", + "ui.dev.audit.title": "Audit Logs", "ui.dev.brand": "Brand", "ui.dev.clients.badge.admin_session": "Admin Session", "ui.dev.clients.badge.tenant_selected": "Tenant Selected", @@ -1799,6 +3458,8 @@ const Map enStrings = { "ui.dev.clients.consents.export_csv": "Export CSV", "ui.dev.clients.consents.filters.advanced": "Advanced Filters", "ui.dev.clients.consents.revoke": "Revoke", + "ui.dev.clients.consents.revoked_at": "Revoked: ", + "ui.dev.clients.consents.scope_label": "Scope:", "ui.dev.clients.consents.search_placeholder": "Search Placeholder", "ui.dev.clients.consents.stats.active_grants": "Active Grants", "ui.dev.clients.consents.stats.avg_scopes": "Avg. Scopes per User", @@ -1815,36 +3476,33 @@ const Map enStrings = { "ui.dev.clients.consents.table.tenant": "Tenant", "ui.dev.clients.consents.table.user": "User", "ui.dev.clients.consents.title": "User Consent Grants", - "ui.dev.clients.copy_client_id": "Copy client id", - "ui.dev.clients.details.breadcrumb.current": "Current", - "ui.dev.clients.details.breadcrumb.section": "Applications", "ui.dev.clients.details.credentials.client_id": "Client ID", "ui.dev.clients.details.credentials.client_secret": "Client Secret", - "ui.dev.clients.details.credentials.title": "Title", + "ui.dev.clients.details.credentials.title": "Client Credentials", "ui.dev.clients.details.endpoints.read_only": "Read Only", - "ui.dev.clients.details.endpoints.title": "Title", + "ui.dev.clients.details.endpoints.title": "OIDC Endpoints", "ui.dev.clients.details.redirect.callback_label": "Callback Label", "ui.dev.clients.details.redirect.label": "Redirect URIs", "ui.dev.clients.details.redirect.placeholder": "https://your-app.com/callback, http://localhost:3000/auth/callback", "ui.dev.clients.details.redirect.save": "Save", - "ui.dev.clients.details.redirect.title": "Title", + "ui.dev.clients.details.redirect.title": "Redirection Settings", "ui.dev.clients.details.secret.hide": "Hide", "ui.dev.clients.details.secret.rotate": "Rotate", "ui.dev.clients.details.secret.show": "Show", - "ui.dev.clients.details.security.title": "Title", + "ui.dev.clients.details.security.title": "Security Note", "ui.dev.clients.details.tab.connection": "Federation", "ui.dev.clients.details.tab.consents": "Consent & Users", "ui.dev.clients.details.tab.settings": "Settings", "ui.dev.clients.federation.add_btn": "Add Provider", "ui.dev.clients.federation.add_title": "Add Identity Provider", "ui.dev.clients.federation.title": "Identity Federation", - "ui.dev.clients.general.breadcrumb.section": "Applications", + "ui.dev.clients.filter.status_all": "All Statuses", + "ui.dev.clients.filter.type_all": "All Types", + "ui.dev.clients.filter.type_label": "Type:", "ui.dev.clients.general.create": "Create Application", "ui.dev.clients.general.display_new": "Add Connected Application", - "ui.dev.clients.general.footer.client_id": "Client ID", - "ui.dev.clients.general.footer.created_on": "Created On", - "ui.dev.clients.general.identity.description": "Description", + "ui.dev.clients.general.identity.description": "Application Description", "ui.dev.clients.general.identity.description_placeholder": "Description Placeholder", "ui.dev.clients.general.identity.logo": "App Logo URL", @@ -1854,23 +3512,109 @@ const Map enStrings = { "ui.dev.clients.general.identity.name": "Name", "ui.dev.clients.general.identity.name_placeholder": "My Awesome Application", "ui.dev.clients.general.identity.title": "Application Identity", + "ui.dev.clients.general.public_key.allowed_algorithms_info": + "Allowed Algorithms Info", + "ui.dev.clients.general.public_key.auth_method": + "ui.dev.clients.general.public_key.auth_method", + "ui.dev.clients.general.public_key.auth_method_client_secret_basic": + "ui.dev.clients.general.public_key.auth_method_client_secret_basic", + "ui.dev.clients.general.public_key.auth_method_none": + "ui.dev.clients.general.public_key.auth_method_none", + "ui.dev.clients.general.public_key.auth_method_private_key_jwt": + "ui.dev.clients.general.public_key.auth_method_private_key_jwt", + "ui.dev.clients.general.public_key.cache.cached_at": "Cached At", + "ui.dev.clients.general.public_key.cache.error": "Last Error", + "ui.dev.clients.general.public_key.cache.expires_at": "Expires At", + "ui.dev.clients.general.public_key.cache.failures": "Consecutive Failures", + "ui.dev.clients.general.public_key.cache.kids": "Cached KIDs", + "ui.dev.clients.general.public_key.cache.last_checked_at": "Last Checked", + "ui.dev.clients.general.public_key.cache.last_success": + "Last Successful Verification", + "ui.dev.clients.general.public_key.cache.parsed_key_n": "N", + "ui.dev.clients.general.public_key.cache.parsed_keys": "Parsed Keys", + "ui.dev.clients.general.public_key.cache.status": "Status", + "ui.dev.clients.general.public_key.cache.title": "JWKS Cache", + "ui.dev.clients.general.public_key.cache.uri": "JWKS URI", + "ui.dev.clients.general.public_key.guide_toggle": + "ui.dev.clients.general.public_key.guide_toggle", + "ui.dev.clients.general.public_key.headless_disabled": + "ui.dev.clients.general.public_key.headless_disabled", + "ui.dev.clients.general.public_key.headless_enabled": + "ui.dev.clients.general.public_key.headless_enabled", + "ui.dev.clients.general.public_key.headless_toggle": "Headless Login", + "ui.dev.clients.general.public_key.jwks_inline": + "ui.dev.clients.general.public_key.jwks_inline", + "ui.dev.clients.general.public_key.jwks_inline_placeholder": + "ui.dev.clients.general.public_key.jwks_inline_placeholder", + "ui.dev.clients.general.public_key.jwks_uri": "JWKS URI", + "ui.dev.clients.general.public_key.jwks_uri_placeholder": + "https://rp.example.com/.well-known/jwks.json", + "ui.dev.clients.general.public_key.request_object_alg": + "ui.dev.clients.general.public_key.request_object_alg", + "ui.dev.clients.general.public_key.request_object_alg_placeholder": + "ui.dev.clients.general.public_key.request_object_alg_placeholder", + "ui.dev.clients.general.public_key.revoke_cache": "Revoke Cache", + "ui.dev.clients.general.public_key.source": + "ui.dev.clients.general.public_key.source", + "ui.dev.clients.general.public_key.source_uri": + "ui.dev.clients.general.public_key.source_uri", + "ui.dev.clients.general.public_key.title": "Public Key Registration", + "ui.dev.clients.general.public_key.validation_title": "Check before saving", "ui.dev.clients.general.redirect.label": "Redirect URIs", "ui.dev.clients.general.redirect.placeholder": "Placeholder", "ui.dev.clients.general.scopes.add": "Scope Add", "ui.dev.clients.general.scopes.description_placeholder": "Description Placeholder", "ui.dev.clients.general.scopes.name_placeholder": "e.g. profile", + "ui.dev.clients.general.scopes.table.contact_title": + "ui.dev.clients.general.scopes.table.contact_title", "ui.dev.clients.general.scopes.table.delete": "Delete", - "ui.dev.clients.general.scopes.table.description": "Description", + "ui.dev.clients.general.scopes.table.description": "Scope Description", + "ui.dev.clients.general.scopes.table.invalid_format": + "ui.dev.clients.general.scopes.table.invalid_format", + "ui.dev.clients.general.scopes.table.login_id_help": + "ui.dev.clients.general.scopes.table.login_id_help", "ui.dev.clients.general.scopes.table.mandatory": "Mandatory", "ui.dev.clients.general.scopes.table.name": "Scope Name", + "ui.dev.clients.general.scopes.table.password_title": + "ui.dev.clients.general.scopes.table.password_title", + "ui.dev.clients.general.scopes.table.reset_password": + "ui.dev.clients.general.scopes.table.reset_password", + "ui.dev.clients.general.scopes.table.reset_password_confirm": + "ui.dev.clients.general.scopes.table.reset_password_confirm", + "ui.dev.clients.general.scopes.table.reset_password_label": + "ui.dev.clients.general.scopes.table.reset_password_label", + "ui.dev.clients.general.scopes.table.role_super_admin": + "ui.dev.clients.general.scopes.table.role_super_admin", + "ui.dev.clients.general.scopes.table.role_tenant_admin": + "ui.dev.clients.general.scopes.table.role_tenant_admin", + "ui.dev.clients.general.scopes.table.role_user": + "ui.dev.clients.general.scopes.table.role_user", + "ui.dev.clients.general.scopes.table.status_active": + "ui.dev.clients.general.scopes.table.status_active", + "ui.dev.clients.general.scopes.table.status_inactive": + "ui.dev.clients.general.scopes.table.status_inactive", + "ui.dev.clients.general.scopes.table.status_title": + "ui.dev.clients.general.scopes.table.status_title", "ui.dev.clients.general.scopes.title": "Scopes", + "ui.dev.clients.general.security.headless_login_enable": + "Headless Login (Custom Login UI)", + "ui.dev.clients.general.security.headless_login_enable_help": + "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page.", "ui.dev.clients.general.security.pkce": "PKCE", "ui.dev.clients.general.security.private": "Server Side App", "ui.dev.clients.general.security.title": "Security Settings", + "ui.dev.clients.general.security.trusted_rp_enable": + "ui.dev.clients.general.security.trusted_rp_enable", + "ui.dev.clients.general.security.trusted_rp_enable_help": + "ui.dev.clients.general.security.trusted_rp_enable_help", + "ui.dev.clients.general.subtitle": + "Manage application settings and security configuration.", "ui.dev.clients.general.title_create": "Create Client", "ui.dev.clients.general.title_edit": "Client Settings", + "ui.dev.clients.help.docs_body": "ui.dev.clients.help.docs_body", "ui.dev.clients.help.docs_title": "Docs & Examples", + "ui.dev.clients.help.subtitle": "ui.dev.clients.help.subtitle", "ui.dev.clients.help.title": "Need help with OIDC configuration?", "ui.dev.clients.help.view_guides": "View guides", "ui.dev.clients.list.title": "Connected Applications", @@ -1882,8 +3626,12 @@ const Map enStrings = { "ui.dev.clients.owner.scope": "Scope: TENANT-12", "ui.dev.clients.owner.subtitle": "Tenant admin on-call", "ui.dev.clients.owner.title": "Owner", + "ui.dev.clients.registry.description": "ui.dev.clients.registry.description", "ui.dev.clients.registry.subtitle": "Applications", "ui.dev.clients.registry.title": "RP registry", + "ui.dev.clients.scopes.email": "ui.dev.clients.scopes.email", + "ui.dev.clients.scopes.openid": "ui.dev.clients.scopes.openid", + "ui.dev.clients.scopes.profile": "ui.dev.clients.scopes.profile", "ui.dev.clients.search_placeholder": "Search by app name or ID...", "ui.dev.clients.table.actions": "Actions", "ui.dev.clients.table.application": "Application", @@ -1893,6 +3641,7 @@ const Map enStrings = { "ui.dev.clients.table.type": "Type", "ui.dev.clients.tenant_scoped": "Tenant-scoped", "ui.dev.clients.type.pkce": "PKCE", + "ui.dev.clients.type.pkce_headless": "Headless PKCE", "ui.dev.clients.type.private": "Server side App", "ui.dev.clients.untitled": "Untitled", "ui.dev.console_title": "Developer Console", @@ -1904,7 +3653,8 @@ const Map enStrings = { "ui.dev.dashboard.ops.card.consent_revoked": "Consent Revoked", "ui.dev.dashboard.ops.card.hydra_status": "Hydra Status", "ui.dev.dashboard.ops.card.rp_requests": "Rp Requests", - "ui.dev.dashboard.ops.subtitle": "Subtitle", + "ui.dev.dashboard.ops.subtitle": + "Operational indicators for the current developer workspace.", "ui.dev.dashboard.ops.tag.consent": "Consent grants", "ui.dev.dashboard.ops.tag.rp_status": "RP status", "ui.dev.dashboard.ops.title": "Ops board", @@ -1917,24 +3667,54 @@ const Map enStrings = { "ui.dev.header.subtitle": "Manage your applications", "ui.dev.nav.clients": "Connected Application", "ui.dev.nav.logout": "Logout", + "ui.dev.profile.basic.email": "Email", + "ui.dev.profile.basic.id": "Account ID", + "ui.dev.profile.basic.name": "Name", + "ui.dev.profile.basic.phone": "Phone Number", + "ui.dev.profile.basic.title": "User Info", + "ui.dev.profile.error": "Failed to load profile.", + "ui.dev.profile.loading": "Loading profile...", "ui.dev.profile.menu_aria": "Open account menu", "ui.dev.profile.menu_title": "Account", + "ui.dev.profile.org.company_code": "Company Code", + "ui.dev.profile.org.tenant": "Tenant", + "ui.dev.profile.org.title": "Organization Info", + "ui.dev.profile.role.current": "Current Role", + "ui.dev.profile.role.description": + "The permission level granted to this account.", + "ui.dev.profile.role.title": "System Role", + "ui.dev.profile.subtitle": "View user details and assigned roles.", + "ui.dev.profile.tab.basic": "Basic Info", + "ui.dev.profile.tab.role": "Roles & Permissions", + "ui.dev.profile.title": "My Profile", "ui.dev.profile.unknown_email": "unknown@example.com", "ui.dev.profile.unknown_name": "Unknown User", "ui.dev.scope_badge": "Scoped to /dev", "ui.dev.session.active": "Checking expiration...", + "ui.dev.session.auto_extend": "Session expiry controls", + "ui.dev.session.disabled": "Auto extend disabled", "ui.dev.session.expired": "Session expired", "ui.dev.session.expiring": "Expiring soon: {{minutes}}m {{seconds}}s left", - "ui.dev.session.refresh": "Refresh session expiry", - "ui.dev.session.refreshing": "Refreshing session expiry...", + "ui.dev.session.refresh": "ui.dev.session.refresh", + "ui.dev.session.refreshing": "ui.dev.session.refreshing", "ui.dev.session.remaining": "Expires in: {{minutes}}m {{seconds}}s", "ui.dev.session.unknown": "Unknown", + "ui.dev.tenant.single_notice": + "You belong to a single tenant, so no switching is needed.", + "ui.dev.tenant.switch_success": "Tenant switch completed", + "ui.dev.tenant.workspace": "Workspace tenant (context)", + "ui.dev.tenant.workspace_desc": + "Select and save the current working tenant to change API request context.", "ui.userfront.app_label.admin_console": "Admin Console", "ui.userfront.app_label.baron": "Baron", "ui.userfront.app_label.dev_console": "Dev Console", "ui.userfront.app_title": "Baron SW Portal", + "ui.userfront.audit.filter.title": "Manage My Activity", + "ui.userfront.audit.filter.toggle_label": "Show active sessions only", + "ui.userfront.audit.table.action": "Action", "ui.userfront.audit.table.app": "App", "ui.userfront.audit.table.auth_method": "Auth Method", + "ui.userfront.audit.table.browser": "Browser", "ui.userfront.audit.table.date": "Date", "ui.userfront.audit.table.device": "Device", "ui.userfront.audit.table.ip": "IP", @@ -1944,15 +3724,29 @@ const Map enStrings = { "ui.userfront.audit.table.status": "Status", "ui.userfront.auth_method.ory": "Ory", "ui.userfront.auth_method.session": "Session", + "ui.userfront.consent.accept": "Agree and continue", + "ui.userfront.consent.cancel.confirm_button": "Yes, cancel", + "ui.userfront.consent.cancel.title": "Cancel consent", + "ui.userfront.consent.requested_scopes": "Requested permissions", + "ui.userfront.consent.title": "Permission request", "ui.userfront.dashboard.activity.linked": "Linked", "ui.userfront.dashboard.approved_session.default": "Default", - "ui.userfront.dashboard.approved_session.userfront": "Userfront", - "ui.userfront.dashboard.last_auth_label": "Last Auth Label", - "ui.userfront.dashboard.revoke.confirm_button": "Confirm Button", - "ui.userfront.dashboard.revoke.title": "Title", - "ui.userfront.dashboard.scopes.title": "Permission (Scopes)", + "ui.userfront.dashboard.approved_session.userfront": + "Approved UserFront session ID", + "ui.userfront.dashboard.last_auth_label": "Last sign-in", + "ui.userfront.dashboard.link_status_label": "Link status", + "ui.userfront.dashboard.revoke.confirm_button": "Disconnect", + "ui.userfront.dashboard.revoke.title": "Disconnect", + "ui.userfront.dashboard.scopes.title": "Consent scopes", + "ui.userfront.dashboard.sessions.active_badge": "Active", + "ui.userfront.dashboard.sessions.current_badge": "Current", + "ui.userfront.dashboard.sessions.current_disabled": "Current session", + "ui.userfront.dashboard.sessions.revoke.action": "End session", + "ui.userfront.dashboard.sessions.revoke.title": "End session", + "ui.userfront.dashboard.sessions.unknown_device": "Unknown device", + "ui.userfront.dashboard.sessions.unknown_session": "Session", "ui.userfront.dashboard.status.revoked": "Revoked", - "ui.userfront.dashboard.status_history": "Status History", + "ui.userfront.dashboard.status_history": "Link details", "ui.userfront.device.android": "Mobile(Android)", "ui.userfront.device.ios": "Mobile(iOS)", "ui.userfront.device.linux": "Desktop(Linux)", @@ -1960,39 +3754,39 @@ const Map enStrings = { "ui.userfront.device.windows": "Desktop(Windows)", "ui.userfront.error.go_home": "Go Home", "ui.userfront.error.go_login": "Go Login", - "ui.userfront.forgot.heading": "Heading", - "ui.userfront.forgot.input_label": "Input Label", - "ui.userfront.forgot.submit": "Submit", - "ui.userfront.forgot.title": "Title", - "ui.userfront.login.action.submit": "Submit", - "ui.userfront.login.field.login_id": "Emain or Phone Number", + "ui.userfront.forgot.heading": "Forgot your password?", + "ui.userfront.forgot.input_label": "Email address or phone number", + "ui.userfront.forgot.submit": "Send reset link", + "ui.userfront.forgot.title": "Reset password", + "ui.userfront.login.action.submit": "Sign in", + "ui.userfront.login.field.login_id": "Email address or phone number", "ui.userfront.login.field.password": "Password", "ui.userfront.login.forgot_password": "Forgot Password", - "ui.userfront.login.link.action_label": "Action Label", - "ui.userfront.login.link.code_only": "Code Only", - "ui.userfront.login.link.page_title": "Page Title", - "ui.userfront.login.link.resend_with_time": "Resend With Time", + "ui.userfront.login.link.action_label": "Go to sign-in", + "ui.userfront.login.link.code_only": "Get code only ({{time}})", + "ui.userfront.login.link.page_title": "Link sign-in", + "ui.userfront.login.link.resend_with_time": "Resend ({{time}})", "ui.userfront.login.link.send": "Send", - "ui.userfront.login.link.title": "Title", + "ui.userfront.login.link.title": "Link sign-in complete", "ui.userfront.login.qr.expired": "Expired", "ui.userfront.login.qr.refresh": "Refresh", "ui.userfront.login.qr.remaining": "Remaining: {{time}}", - "ui.userfront.login.short_code.digits": "Digits", - "ui.userfront.login.short_code.expire_time": "Expire Time", - "ui.userfront.login.short_code.prefix": "Prefix", - "ui.userfront.login.short_code.submit": "Submit", - "ui.userfront.login.signup": "Signup", - "ui.userfront.login.tabs.link": "Link/Code", + "ui.userfront.login.short_code.digits": "6 digits", + "ui.userfront.login.short_code.expire_time": "Expires in {{time}}", + "ui.userfront.login.short_code.prefix": "2 letters", + "ui.userfront.login.short_code.submit": "Sign in with code", + "ui.userfront.login.signup": "Sign up", + "ui.userfront.login.tabs.link": "Sign-in link", "ui.userfront.login.tabs.password": "Password", "ui.userfront.login.tabs.qr": "QR Code", - "ui.userfront.login.unregistered.action": "Action", - "ui.userfront.login.unregistered.title": "Title", - "ui.userfront.login.verification.action_label": "Confirm", - "ui.userfront.login.verification.page_title": "Page Title", - "ui.userfront.login.verification.title": "Title", - "ui.userfront.login_success.later": "Later", - "ui.userfront.login_success.qr": "QR", - "ui.userfront.login_success.title": "Title", + "ui.userfront.login.unregistered.action": "Create an account", + "ui.userfront.login.unregistered.title": "Account not found", + "ui.userfront.login.verification.action_label": "Done", + "ui.userfront.login.verification.page_title": "Sign-in approval", + "ui.userfront.login.verification.title": "Approval complete", + "ui.userfront.login_success.later": "Do this later (go to dashboard)", + "ui.userfront.login_success.qr": "Use QR approval", + "ui.userfront.login_success.title": "Sign-in complete", "ui.userfront.nav.dashboard": "Dashboard", "ui.userfront.nav.logout": "Logout", "ui.userfront.nav.profile": "Profile", @@ -2004,56 +3798,58 @@ const Map enStrings = { "ui.userfront.profile.field.email": "Email", "ui.userfront.profile.field.name": "Name", "ui.userfront.profile.field.tenant": "Tenant", - "ui.userfront.profile.manage": "Manage", + "ui.userfront.profile.manage": "Manage profile", "ui.userfront.profile.password.change": "Change", "ui.userfront.profile.password.confirm": "Confirm", "ui.userfront.profile.password.current": "Current", "ui.userfront.profile.password.forgot": "Forgot", "ui.userfront.profile.password.new": "New", - "ui.userfront.profile.password.title": "Title", - "ui.userfront.profile.phone.code_hint": "Code Hint", - "ui.userfront.profile.phone.request_code": "Request Code", + "ui.userfront.profile.password.title": "Change password", + "ui.userfront.profile.phone.code_hint": "6-digit code", + "ui.userfront.profile.phone.request_code": "Send code", "ui.userfront.profile.phone.title": "Phone number", "ui.userfront.profile.section.basic": "Basic", "ui.userfront.profile.section.organization": "Organization", "ui.userfront.profile.section.security": "Security", "ui.userfront.profile.user_fallback": "User", - "ui.userfront.qr.request_permission": "Request Permission", + "ui.userfront.qr.request_permission": "Allow camera access", "ui.userfront.qr.rescan": "Rescan", - "ui.userfront.qr.result_failure": "Result Failure", - "ui.userfront.qr.result_success": "Result Success", + "ui.userfront.qr.result_failure": "Approval failed", + "ui.userfront.qr.result_success": "Approval complete", "ui.userfront.qr.title": "Scan QR Code", "ui.userfront.reset.confirm_password": "Confirm Password", "ui.userfront.reset.new_password": "New Password", "ui.userfront.reset.submit": "Submit", - "ui.userfront.reset.subtitle": "Subtitle", - "ui.userfront.reset.title": "Title", + "ui.userfront.reset.subtitle": "Set a new password", + "ui.userfront.reset.title": "Create a new password", "ui.userfront.sections.apps": "Apps", "ui.userfront.sections.audit": "Audit", - "ui.userfront.session.active": "Active", + "ui.userfront.sections.sessions": "Sessions", + "ui.userfront.session.active": "Active session", "ui.userfront.session.unknown": "Unknown", - "ui.userfront.signup.agreement.all": "All", - "ui.userfront.signup.agreement.privacy_title": "Privacy Title", - "ui.userfront.signup.agreement.tos_title": "Tos Title", - "ui.userfront.signup.auth.code_label": "Code Label", - "ui.userfront.signup.auth.email.label": "Label", - "ui.userfront.signup.auth.email.title": "Title", - "ui.userfront.signup.auth.request_code": "Request Code", - "ui.userfront.signup.complete": "Complete", - "ui.userfront.signup.next_step": "Next Step", + "ui.userfront.signup.agreement.all": "Agree to all", + "ui.userfront.signup.agreement.privacy_title": "Privacy Policy (Required)", + "ui.userfront.signup.agreement.required": "Required", + "ui.userfront.signup.agreement.tos_title": "Terms of Service (Required)", + "ui.userfront.signup.auth.code_label": "6-digit verification code", + "ui.userfront.signup.auth.email.label": "Email address", + "ui.userfront.signup.auth.email.title": "Email verification", + "ui.userfront.signup.auth.request_code": "Send code", + "ui.userfront.signup.complete": "Finish sign-up", + "ui.userfront.signup.next_step": "Next", "ui.userfront.signup.password.confirm_label": "Password Confirm", "ui.userfront.signup.password.label": "Password", - "ui.userfront.signup.phone.label": "Label", - "ui.userfront.signup.phone.title": "Title", + "ui.userfront.signup.phone.label": "Phone number (no hyphens)", + "ui.userfront.signup.phone.title": "Phone verification", "ui.userfront.signup.profile.affiliation_type": "Affiliation Type", "ui.userfront.signup.profile.company": "Company", "ui.userfront.signup.profile.department": "Department", - "ui.userfront.signup.profile.department_optional": "Department Optional", + "ui.userfront.signup.profile.department_optional": "Department (optional)", "ui.userfront.signup.profile.name": "Name", - "ui.userfront.signup.steps.agreement": "Agreement", + "ui.userfront.signup.steps.agreement": "Terms", "ui.userfront.signup.steps.password": "Password", "ui.userfront.signup.steps.profile": "Profile", - "ui.userfront.signup.steps.verify": "Verify", - "ui.userfront.signup.success.action": "Action", - "ui.userfront.signup.title": "Title", + "ui.userfront.signup.steps.verify": "Verification", + "ui.userfront.signup.success.action": "Go to sign-in", + "ui.userfront.signup.title": "Sign up", }; diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 8fbabee1..774ecb66 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -24,6 +24,9 @@ import 'core/services/logger_service.dart'; import 'core/services/null_check_recovery.dart'; import 'core/services/web_window.dart'; import 'core/notifiers/auth_notifier.dart'; +import 'core/theme/app_theme.dart'; +import 'core/theme/theme_controller.dart'; +import 'core/theme/theme_scope.dart'; import 'core/i18n/locale_gate.dart'; import 'core/i18n/locale_registry.dart'; import 'core/i18n/locale_utils.dart'; @@ -106,6 +109,8 @@ void main() async { // 0. Initialize Logger LoggerService.init(); + await ThemeController.app.restore(); + await ThemeController.auth.restore(); // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 await _loadBundledFonts(); @@ -177,12 +182,18 @@ final _router = GoRouter( GoRoute( path: 'dashboard', builder: (context, state) { - return const DashboardScreen(); + return ScopedTheme( + controller: ThemeController.app, + child: const DashboardScreen(), + ); }, ), GoRoute( path: 'profile', - builder: (context, state) => const ProfilePage(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.app, + child: const ProfilePage(), + ), ), GoRoute( path: 'signin', @@ -192,10 +203,13 @@ final _router = GoRouter( final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url']; - return LoginScreen( - key: state.pageKey, - loginChallenge: loginChallenge, - redirectUrl: redirectUrl, + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen( + key: state.pageKey, + loginChallenge: loginChallenge, + redirectUrl: redirectUrl, + ), ); }, ), @@ -208,10 +222,13 @@ final _router = GoRouter( final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url']; - return LoginScreen( - key: state.pageKey, - loginChallenge: loginChallenge, - redirectUrl: redirectUrl, + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen( + key: state.pageKey, + loginChallenge: loginChallenge, + redirectUrl: redirectUrl, + ), ); }, ), @@ -227,88 +244,137 @@ final _router = GoRouter( ), ); } - return ConsentScreen(consentChallenge: consentChallenge); + return ScopedTheme( + controller: ThemeController.auth, + child: ConsentScreen(consentChallenge: consentChallenge), + ); }, ), GoRoute( path: 'signup', - builder: (context, state) => const SignupScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const SignupScreen(), + ), ), GoRoute( path: 'registration', - builder: (context, state) => const SignupScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const SignupScreen(), + ), ), GoRoute( path: 'verify', - builder: (context, state) => LoginScreen(key: state.pageKey), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen(key: state.pageKey), + ), ), GoRoute( path: 'verify/:token', builder: (context, state) { final token = state.pathParameters['token']; - return LoginScreen( - key: state.pageKey, - verificationToken: token, + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen( + key: state.pageKey, + verificationToken: token, + ), ); }, ), GoRoute( path: 'verification', - builder: (context, state) => LoginScreen(key: state.pageKey), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen(key: state.pageKey), + ), ), GoRoute( path: 'l/:shortCode', builder: (context, state) { - return LoginScreen(key: state.pageKey); + return ScopedTheme( + controller: ThemeController.auth, + child: LoginScreen(key: state.pageKey), + ); }, ), GoRoute( path: 'forgot-password', - builder: (context, state) => const ForgotPasswordScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const ForgotPasswordScreen(), + ), ), GoRoute( path: 'recovery', - builder: (context, state) => const ForgotPasswordScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const ForgotPasswordScreen(), + ), ), GoRoute( path: 'reset-password', - builder: (context, state) => const ResetPasswordScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const ResetPasswordScreen(), + ), ), GoRoute( path: 'error', builder: (context, state) { final params = state.uri.queryParameters; - return ErrorScreen( - errorId: params['id'], - errorCode: params['error'], - description: params['error_description'] ?? params['message'], + return ScopedTheme( + controller: ThemeController.auth, + child: ErrorScreen( + errorId: params['id'], + errorCode: params['error'], + description: + params['error_description'] ?? params['message'], + ), ); }, ), GoRoute( path: 'settings', - builder: (context, state) => ErrorScreen( - errorCode: 'settings_disabled', - description: tr('msg.userfront.settings.disabled'), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: ErrorScreen( + errorCode: 'settings_disabled', + description: tr('msg.userfront.settings.disabled'), + ), ), ), GoRoute( path: 'approve', - builder: (context, state) => - ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: ApproveQrScreen( + pendingRef: state.uri.queryParameters['ref'], + ), + ), ), GoRoute( path: 'ql/:ref', - builder: (context, state) => - ApproveQrScreen(pendingRef: state.pathParameters['ref']), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: ApproveQrScreen(pendingRef: state.pathParameters['ref']), + ), ), GoRoute( path: 'scan', - builder: (context, state) => const QRScanScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.auth, + child: const QRScanScreen(), + ), ), GoRoute( path: 'admin/users', - builder: (context, state) => const UserManagementScreen(), + builder: (context, state) => ScopedTheme( + controller: ThemeController.app, + child: const UserManagementScreen(), + ), ), ], ), @@ -376,40 +442,10 @@ class BaronSSOApp extends StatelessWidget { children: [if (child != null) child, const ToastViewport()], ); }, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base - brightness: Brightness.light, - ), - useMaterial3: true, - fontFamily: 'NotoSansKR', - pageTransitionsTheme: const PageTransitionsTheme( - builders: { - TargetPlatform.android: NoTransitionsBuilder(), - TargetPlatform.iOS: NoTransitionsBuilder(), - TargetPlatform.linux: NoTransitionsBuilder(), - TargetPlatform.macOS: NoTransitionsBuilder(), - TargetPlatform.windows: NoTransitionsBuilder(), - TargetPlatform.fuchsia: NoTransitionsBuilder(), - }, - ), - ), + theme: buildLightTheme(), + darkTheme: buildDarkTheme(), + themeMode: ThemeMode.light, routerConfig: _router, ); } } - -class NoTransitionsBuilder extends PageTransitionsBuilder { - const NoTransitionsBuilder(); - - @override - Widget buildTransitions( - PageRoute route, - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - return child; - } -} diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index fecd33f1..238c821f 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -184,6 +184,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" + url: "https://pub.dev" + source: hosted + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -388,6 +396,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider_linux: dependency: transitive description: @@ -485,7 +501,7 @@ packages: source: hosted version: "3.2.0" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" @@ -753,6 +769,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373" + url: "https://pub.dev" + source: hosted + version: "1.1.21" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" vector_math: dependency: transitive description: @@ -825,6 +865,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml index 270c2fb4..cc71655e 100644 --- a/userfront/pubspec.yaml +++ b/userfront/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: go_router: ^17.0.1 http: ^1.6.0 flutter_dotenv: ^6.0.0 + flutter_svg: ^2.2.1 url_launcher: ^6.3.2 logging: ^1.2.0 logger: ^2.0.0 @@ -48,6 +49,7 @@ dependencies: easy_localization: ^3.0.7 toml: ^0.15.0 web: ^1.1.0 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: diff --git a/userfront/test/linked_rp_launch_test.dart b/userfront/test/linked_rp_launch_test.dart new file mode 100644 index 00000000..3e06e01c --- /dev/null +++ b/userfront/test/linked_rp_launch_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart'; +import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart'; + +LinkedRp _linkedRp({ + required String status, + String url = '', + String initUrl = '', +}) { + return LinkedRp( + id: 'client-1', + name: 'Example App', + logo: '', + url: url, + initUrl: initUrl, + status: status, + scopes: const ['openid', 'profile'], + lastAuthenticatedAt: null, + ); +} + +void main() { + test('LinkedRp.fromJson은 init_url을 읽는다', () { + final rp = LinkedRp.fromJson({ + 'id': 'client-1', + 'name': 'Example App', + 'status': 'active', + 'url': 'https://example.com', + 'init_url': 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + }); + + expect( + rp.initUrl, + 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ); + }); + + test('활성 앱은 initUrl을 우선 진입 URL로 사용한다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp( + status: 'active', + url: 'https://example.com', + initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ), + ); + + expect( + launchUrl, + 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ); + }); + + test('활성 앱은 initUrl이 없으면 기존 url로 폴백한다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp(status: 'active', url: 'https://example.com'), + ); + + expect(launchUrl, 'https://example.com'); + }); + + test('비활성 앱은 진입 URL을 만들지 않는다', () { + final launchUrl = resolveLinkedRpLaunchUrl( + _linkedRp( + status: 'inactive', + url: 'https://example.com', + initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1', + ), + ); + + expect(launchUrl, isNull); + }); +} diff --git a/userfront/test/logout_service_test.dart b/userfront/test/logout_service_test.dart new file mode 100644 index 00000000..b9cdc5ec --- /dev/null +++ b/userfront/test/logout_service_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/services/logout_service.dart'; + +void main() { + test('현재 세션이 있으면 서버 세션 종료 후 로컬 로그아웃을 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return 'current-sid'; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']); + }); + + test('현재 세션이 없으면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return null; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'clear', 'notify']); + }); + + test('서버 세션 종료가 실패해도 로컬 로그아웃은 계속 진행한다', () async { + final events = []; + final service = LogoutService( + loadCurrentSessionId: () async { + events.add('load'); + return 'current-sid'; + }, + revokeSession: (sessionId) async { + events.add('revoke:$sessionId'); + throw Exception('revoke failed'); + }, + clearAuth: () { + events.add('clear'); + }, + notifyAuthChanged: () { + events.add('notify'); + }, + ); + + await service.logout(); + + expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']); + }); +} diff --git a/userfront/test/theme_controller_test.dart b/userfront/test/theme_controller_test.dart new file mode 100644 index 00000000..447255c9 --- /dev/null +++ b/userfront/test/theme_controller_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:userfront/core/theme/theme_controller.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await ThemeController.app.setThemeMode(ThemeMode.light); + }); + + test('저장된 dark 값을 복원한다', () async { + SharedPreferences.setMockInitialValues({ + ThemeController.appStorageKey: 'dark', + }); + + await ThemeController.app.restore(); + + expect(ThemeController.app.value, ThemeMode.dark); + }); + + test('toggle 결과를 저장한다', () async { + await ThemeController.app.restore(); + await ThemeController.app.toggle(); + + final prefs = await SharedPreferences.getInstance(); + expect(ThemeController.app.value, ThemeMode.dark); + expect(prefs.getString(ThemeController.appStorageKey), 'dark'); + }); +}