From c8e88b2cae9597e086fec699b52703c782b9a48f Mon Sep 17 00:00:00 2001 From: Jeonghwan Lee Date: Tue, 21 Jul 2026 10:57:11 +0900 Subject: [PATCH 01/24] fix(console): soft-block non-owner login and expire withdrawn sessions The console single-admin lock hard-refused a non-owner at the OAuth callback (admin_locked), so once the owner withdrew their cloud account the console was effectively bricked: a new account could not even learn who owned it or switch. Separately the local session was validated only by its 12h TTL, so a withdrawn owner kept reporting logged-in from the cached snapshot. - login.go: a non-owner login now succeeds (soft block) and receives a console session, but the org-admin registrar runs only for the owner, so a non-owner never takes over admin authority. Remove failOwnerLocked and the admin_locked bounce. - service.go: GET /console/session revalidates a live session against the cloud (GET /me). A 401 (nil principal, nil error) is the signal a withdrawn account produces and expires the local login on the spot. The response reports is_owner (+ owner_email) so the app can gate. - frontend: RequireAuth shows a new OwnerLockedNotice for is_owner:false instead of the admin shell; TSession carries is_owner/owner_email. Co-Authored-By: Claude Fable 5 --- .../src/components/auth/OwnerLockedNotice.tsx | 63 +++++++++ frontend/src/components/auth/RequireAuth.tsx | 8 ++ .../auth/__tests__/RequireAuth.test.tsx | 16 +++ frontend/src/types/authTypes.ts | 14 +- internal/console/console_test.go | 13 +- internal/console/login.go | 56 ++++---- internal/console/owner_test.go | 127 +++++++++++++++--- internal/console/service.go | 76 ++++++++--- 8 files changed, 298 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/auth/OwnerLockedNotice.tsx diff --git a/frontend/src/components/auth/OwnerLockedNotice.tsx b/frontend/src/components/auth/OwnerLockedNotice.tsx new file mode 100644 index 0000000..aa14caa --- /dev/null +++ b/frontend/src/components/auth/OwnerLockedNotice.tsx @@ -0,0 +1,63 @@ +import { useNavigate } from "react-router"; +import { useQueryClient } from "@tanstack/react-query"; + +import Button from "@/components/elements/Button"; +import RuneMark from "@/components/elements/RuneMark"; +import PublicNavbar from "@/components/navigation/PublicNavbar"; +import { postLogout } from "@/api/authAPIs"; +import { BTN_TEXT, PATH_LIST, QUERY_KEYS } from "@/constants/commonConstants"; + +/** + * OwnerLockedNotice is the soft-block screen shown when a signed-in account is + * NOT the console owner (GET /console/session → is_owner:false). The console is + * a single-admin surface: the first account to sign in claims it, and any other + * account is let in only far enough to learn who owns it and switch — never into + * the admin shell. Sign out returns to the login screen so a different Google + * account can be chosen. + */ +const OwnerLockedNotice = ({ owner }: { owner?: string }) => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const handleSignOut = async () => { + try { + await postLogout(); // idempotent server-side + } finally { + queryClient.setQueryData([QUERY_KEYS.session], { logged_in: false }); + navigate(PATH_LIST.login); + } + }; + + return ( +
+ +
+
+
+ +

사용할 수 없는 계정

+
+

+ 이 콘솔은{" "} + {owner ? ( + {owner} + ) : ( + "다른 계정" + )} + 이(가) 관리하고 있어요. +
+ 이 계정으로는 콘솔을 사용할 수 없습니다. +

+
+
+
+ ); +}; + +export default OwnerLockedNotice; diff --git a/frontend/src/components/auth/RequireAuth.tsx b/frontend/src/components/auth/RequireAuth.tsx index 2378ec6..dd7eb87 100644 --- a/frontend/src/components/auth/RequireAuth.tsx +++ b/frontend/src/components/auth/RequireAuth.tsx @@ -1,5 +1,6 @@ import { Navigate, Outlet } from "react-router"; +import OwnerLockedNotice from "@/components/auth/OwnerLockedNotice"; import { useSessionQuery } from "@/hooks/queries/useSessionQuery"; import { PATH_LIST } from "@/constants/commonConstants"; @@ -8,11 +9,18 @@ import { PATH_LIST } from "@/constants/commonConstants"; * unreachable while logged out, so an absent session redirects to the sign-in * screen. While the session is still resolving it renders nothing (a shared * loading state can slot in here later). + * + * Single-admin soft block: a signed-in account that is not the console owner + * (is_owner === false) reaches the app but cannot use it — show the owner-locked + * notice instead of the shell. is_owner is only ever explicitly false here; an + * older backend that omits it (undefined) is treated as the owner (no gate). */ const RequireAuth = () => { const { data, isPending } = useSessionQuery(); if (isPending) return null; if (!data?.logged_in) return ; + if (data.is_owner === false) + return ; return ; }; diff --git a/frontend/src/components/auth/__tests__/RequireAuth.test.tsx b/frontend/src/components/auth/__tests__/RequireAuth.test.tsx index 0bee83d..6e93ffd 100644 --- a/frontend/src/components/auth/__tests__/RequireAuth.test.tsx +++ b/frontend/src/components/auth/__tests__/RequireAuth.test.tsx @@ -45,6 +45,22 @@ describe("RequireAuth", () => { expect(await screen.findByText("TEAMS CONTENT")).toBeInTheDocument(); }); + it("shows the owner-locked notice for a non-owner (soft block)", async () => { + vi.spyOn(authAPIs, "getConsoleSession").mockResolvedValue( + jsonRes({ + logged_in: true, + expires_at: "2026-07-16T21:00:00Z", + me: { email: "bob@corp.com" }, + is_owner: false, + owner_email: "alice@corp.com", + }), + ); + renderGuard(); + expect(await screen.findByText("사용할 수 없는 계정")).toBeInTheDocument(); + expect(screen.getByText("alice@corp.com")).toBeInTheDocument(); + expect(screen.queryByText("TEAMS CONTENT")).not.toBeInTheDocument(); + }); + it("redirects to /login when logged out", async () => { vi.spyOn(authAPIs, "getConsoleSession").mockResolvedValue( jsonRes({ logged_in: false }), diff --git a/frontend/src/types/authTypes.ts b/frontend/src/types/authTypes.ts index cd42f28..5162584 100644 --- a/frontend/src/types/authTypes.ts +++ b/frontend/src/types/authTypes.ts @@ -15,7 +15,19 @@ export type TSessionMe = { /** * TSession is the /console/session response — the single truth for the route * guard. It always resolves 200; the discriminant is `logged_in`. + * + * The console is a single-admin surface: the first account to sign in claims it + * as the owner. `is_owner` is false when a DIFFERENT account is signed in (soft + * block) — it reaches the app but cannot use it, and `owner_email` names the + * owner so the UI can say whom to ask. Both are optional for forward-compat with + * an older backend that omits them (treated as owner — no gate). */ export type TSession = | { logged_in: false } - | { logged_in: true; expires_at: string; me: TSessionMe }; + | { + logged_in: true; + expires_at: string; + me: TSessionMe; + is_owner?: boolean; + owner_email?: string; + }; diff --git a/internal/console/console_test.go b/internal/console/console_test.go index 24a5bcf..b95f643 100644 --- a/internal/console/console_test.go +++ b/internal/console/console_test.go @@ -169,11 +169,20 @@ func TestSessionEndpointNoCookie(t *testing.T) { } func TestSessionEndpointLoggedInIncludesPlan(t *testing.T) { + // The session check revalidates a live session against the cloud, so point at + // a mock whose /me returns 200 (the cloud session still exists). + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/me", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"email": "a@x.io"}) + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + db := openTestDB(t) h, _, err := NewHandler(Deps{ Port: 8787, - APIBaseURL: "http://cloud.invalid", - WebBaseURL: "http://web.invalid", + APIBaseURL: ts.URL, + WebBaseURL: ts.URL, DB: db, }) if err != nil { diff --git a/internal/console/login.go b/internal/console/login.go index f80ba3e..141980b 100644 --- a/internal/console/login.go +++ b/internal/console/login.go @@ -113,9 +113,14 @@ func (s *Service) handleCallback(w http.ResponseWriter, r *http.Request) { me = raw } - // Single-admin binding: the FIRST account to log in claims this console; a - // later login by a different account is refused (BFF spec: the console is a - // single-admin surface). The principal email is the stable ownership key. + // Single-admin ownership: the FIRST account to log in claims this console as + // its owner (the org admin). A later login by a DIFFERENT account is a SOFT + // block — it still receives a console session and reaches the app, but it is + // not granted org-admin authority and the app surfaces an owner-locked notice + // (GET /console/session → is_owner:false). This replaced a hard refusal that + // dead-ended the browser on the login screen: a non-owner (e.g. after the + // owner withdrew their cloud account) could not even learn who owns the + // console or switch accounts. The principal email is the stable ownership key. email := emailFromMe(me) if email == "" { // No principal email means ownership cannot be established or verified — @@ -131,25 +136,25 @@ func (s *Service) handleCallback(w http.ResponseWriter, r *http.Request) { s.failCallback(w, r, "exchange_failed") return } - if !strings.EqualFold(own.Email, email) { - // A different account already owns this console. Best-effort revoke the - // cloud session we just minted (this login gets no console session) and - // bounce to the locked-out login screen naming the owning account. - _ = s.cloud.RevokeSession(r.Context(), tok.CookieName+"="+tok.SessionToken) - s.log.Warn("console: login refused — console is bound to another admin", "owner", own.Email, "attempted", email) - s.failOwnerLocked(w, r, own.Email) - return + if strings.EqualFold(own.Email, email) { + // The owner (first-login claim, or any subsequent owner login): derive the + // single org admin from the console owner (grant authority via SetOrgAdmin). + // The admin is a management-plane identity and is NOT seeded a member + // registry row — an admin who wants to appear in member listings invites an + // email through the normal flow, which creates a real row. Idempotent, + // creates zero group memberships (admin reach is granted, never implied), + // and never blocks login (see the helper). + s.registerOwnerBestEffort(email, me, "login") + } else { + // A different account owns this console. Grant the session (soft block) but + // withhold org-admin authority: the registrar never runs for a non-owner, + // so this account cannot take over the incumbent's admin reach. The app + // reads is_owner:false from /console/session and shows the owner-locked + // notice instead of the admin surfaces. + s.log.Warn("console: non-owner login — session granted, org-admin withheld", + "owner", own.Email, "account", email) } - // First-login registration: derive the single org admin from the console - // owner (grant authority via SetOrgAdmin). The admin is a management-plane - // identity and is NOT seeded a member registry row — an admin who wants to - // appear in member listings and/or be granted memberships invites an email - // (their own or another) through the normal flow, which creates a real row. - // Idempotent, creates zero group memberships (admin reach is granted, never - // implied), and never blocks login (see the helper). - s.registerOwnerBestEffort(email, me, "login") - sess, err := s.sessions.create(tok.SessionToken, tok.CookieName, me) if err != nil { s.log.Warn("console: persist session failed", "err", err.Error()) @@ -188,17 +193,6 @@ func (s *Service) failCallback(w http.ResponseWriter, r *http.Request, code stri http.Redirect(w, r, s.selfBase+"/login?error="+url.QueryEscape(code), http.StatusFound) } -// failOwnerLocked clears any cookie and bounces to the login page with the -// admin_locked code plus the owning account, so the SPA can tell the user which -// account manages this console (and whom to contact). Naming the owner is a -// deliberate choice: the console is a loopback-only surface, the owner email is -// not otherwise sensitive here, and the message is useless without it. -func (s *Service) failOwnerLocked(w http.ResponseWriter, r *http.Request, ownerAccount string) { - http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1}) - loc := s.selfBase + "/login?error=admin_locked&owner=" + url.QueryEscape(ownerAccount) - http.Redirect(w, r, loc, http.StatusFound) -} - func randToken(nbytes int) (string, error) { b := make([]byte, nbytes) if _, err := rand.Read(b); err != nil { diff --git a/internal/console/owner_test.go b/internal/console/owner_test.go index 812d163..68d6249 100644 --- a/internal/console/owner_test.go +++ b/internal/console/owner_test.go @@ -196,15 +196,36 @@ func doLogin(t *testing.T, h http.Handler, code string) *httptest.ResponseRecord } func hasSessionCookie(rr *httptest.ResponseRecorder) bool { + return sessionCookieValue(rr) != "" +} + +// sessionCookieValue returns the value of the session cookie the response set, +// or "" when none was set (or it was cleared). +func sessionCookieValue(rr *httptest.ResponseRecorder) string { for _, c := range rr.Result().Cookies() { - if c.Name == cookieName && c.Value != "" { - return true + if c.Name == cookieName { + return c.Value } } - return false + return "" +} + +// getSession drives GET /console/session with the given session cookie and +// returns the decoded body. +func getSession(t *testing.T, h http.Handler, cookieVal string) map[string]any { + t.Helper() + req := httptest.NewRequest("GET", "/console/session", nil) // same-origin + req.AddCookie(&http.Cookie{Name: cookieName, Value: cookieVal}) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + var body map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("session body: %v (%s)", err, rr.Body.String()) + } + return body } -func TestCallbackBindsFirstAdminAndBlocksOthers(t *testing.T) { +func TestCallbackBindsFirstAdminAndSoftGatesOthers(t *testing.T) { ts := mockLoginCloud(t, map[string]string{ "tokA": "alice@x.io", "tokA2": "alice@x.io", @@ -220,7 +241,8 @@ func TestCallbackBindsFirstAdminAndBlocksOthers(t *testing.T) { t.Fatal(err) } - // First login (alice) claims the console: 302 to "/" with a session cookie. + // First login (alice) claims the console: 302 to "/" with a session cookie, + // and the session reports her as the owner. rr := doLogin(t, h, "tokA") if rr.Code != http.StatusFound { t.Fatalf("first login status = %d, want 302", rr.Code) @@ -229,27 +251,40 @@ func TestCallbackBindsFirstAdminAndBlocksOthers(t *testing.T) { t.Errorf("first login → %q, want redirect to /", loc) } if !hasSessionCookie(rr) { - t.Error("first login set no session cookie") + t.Fatal("first login set no session cookie") + } + if s := getSession(t, h, sessionCookieValue(rr)); s["is_owner"] != true { + t.Errorf("owner session is_owner = %v, want true", s["is_owner"]) } - // A different account (bob) is refused: 302 to the admin_locked screen naming - // the owner, and no session cookie. + // A different account (bob) is SOFT-blocked: it still gets a console session + // (302 to "/", cookie set, no error code), but /console/session reports + // is_owner:false and names the owner so the app can show the owner-locked + // notice instead of the admin shell. rr = doLogin(t, h, "tokB") if rr.Code != http.StatusFound { - t.Fatalf("blocked login status = %d, want 302", rr.Code) + t.Fatalf("non-owner login status = %d, want 302", rr.Code) + } + if loc := rr.Header().Get("Location"); strings.Contains(loc, "error=") { + t.Errorf("non-owner login → %q, want no error (soft block)", loc) } - loc := rr.Header().Get("Location") - if !strings.Contains(loc, "/login?error=admin_locked") { - t.Errorf("blocked login → %q, want error=admin_locked", loc) + bobCookie := sessionCookieValue(rr) + if bobCookie == "" { + t.Fatal("non-owner login set no session cookie (soft block must grant one)") } - if !strings.Contains(loc, "owner="+url.QueryEscape("alice@x.io")) { - t.Errorf("blocked login → %q, want owner=alice@x.io", loc) + bobSess := getSession(t, h, bobCookie) + if bobSess["logged_in"] != true { + t.Errorf("non-owner session logged_in = %v, want true", bobSess["logged_in"]) } - if hasSessionCookie(rr) { - t.Error("blocked login must not set a session cookie") + if bobSess["is_owner"] != false { + t.Errorf("non-owner session is_owner = %v, want false", bobSess["is_owner"]) + } + if bobSess["owner_email"] != "alice@x.io" { + t.Errorf("non-owner session owner_email = %v, want alice@x.io", bobSess["owner_email"]) } - // The owner logging in again (fresh cloud session) still succeeds. + // The owner logging in again (fresh cloud session) still succeeds and stays + // the owner. rr = doLogin(t, h, "tokA2") if rr.Code != http.StatusFound || !hasSessionCookie(rr) { t.Errorf("owner re-login status=%d cookie=%v, want 302 + cookie", rr.Code, hasSessionCookie(rr)) @@ -257,6 +292,54 @@ func TestCallbackBindsFirstAdminAndBlocksOthers(t *testing.T) { if loc := rr.Header().Get("Location"); strings.Contains(loc, "error=") { t.Errorf("owner re-login → %q, want no error", loc) } + if s := getSession(t, h, sessionCookieValue(rr)); s["is_owner"] != true { + t.Errorf("owner re-login session is_owner = %v, want true", s["is_owner"]) + } +} + +func TestSessionExpiresWhenCloudAccountWithdrawn(t *testing.T) { + // After the owner withdraws their cloud account, the cloud cascade-deletes + // its sessions, so GET /me with the stored token returns 401. The console + // session check must detect that and expire the local login on the spot, + // rather than keep reporting logged-in off the local snapshot. + var withdrawn bool + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/local/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + writeJSON(w, http.StatusOK, map[string]string{"session_token": r.FormValue("code"), "cookie_name": "rc_cloud"}) + }) + mux.HandleFunc("GET /api/v1/me", func(w http.ResponseWriter, _ *http.Request) { + if withdrawn { + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "no session") + return + } + writeJSON(w, http.StatusOK, map[string]string{"email": "alice@x.io"}) + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + h, _, err := NewHandler(Deps{Port: 8787, APIBaseURL: ts.URL, WebBaseURL: ts.URL, DB: openTestDB(t)}) + if err != nil { + t.Fatal(err) + } + + rr := doLogin(t, h, "tokA") + if rr.Code != http.StatusFound || !hasSessionCookie(rr) { + t.Fatalf("login status=%d cookie=%v, want 302 + cookie", rr.Code, hasSessionCookie(rr)) + } + cookie := sessionCookieValue(rr) + + // While the account exists the session check reports logged-in. + if s := getSession(t, h, cookie); s["logged_in"] != true { + t.Fatalf("pre-withdrawal logged_in = %v, want true", s["logged_in"]) + } + + // After withdrawal the cloud rejects the stored token (401) → the session + // check expires the local login. + withdrawn = true + if s := getSession(t, h, cookie); s["logged_in"] != false { + t.Errorf("post-withdrawal logged_in = %v, want false", s["logged_in"]) + } } // --- owner → registrar (org-admin derivation) ------------------------------- @@ -342,12 +425,12 @@ func TestOwnerRegistrarRunsOnClaimingLoginOnly(t *testing.T) { t.Fatalf("registrar after first login = %v, want [alice@x.io]", emails) } - // A refused login (different account) must NOT reach the registrar: the - // incumbent keeps admin authority. - if rr := doLogin(t, h, "tokB"); !strings.Contains(rr.Header().Get("Location"), "admin_locked") { - t.Fatalf("bob's login was not refused") + // A non-owner login is soft-blocked: it succeeds (302 + session cookie) but + // must NOT reach the registrar — the incumbent keeps admin authority. + if rr := doLogin(t, h, "tokB"); rr.Code != http.StatusFound || !hasSessionCookie(rr) { + t.Fatalf("non-owner login status=%d cookie=%v, want 302 + cookie", rr.Code, hasSessionCookie(rr)) } if len(emails) != 1 { - t.Errorf("registrar ran for a refused login: %v", emails) + t.Errorf("registrar ran for a non-owner login: %v", emails) } } diff --git a/internal/console/service.go b/internal/console/service.go index 3437933..0f443d9 100644 --- a/internal/console/service.go +++ b/internal/console/service.go @@ -236,28 +236,66 @@ func (s *Service) registerOwnerBestEffort(email string, me json.RawMessage, at s } // handleSession (GET /console/session) is the route guard's single source of -// truth, so it always returns 200 — never 401. +// truth, so it always returns 200 — never 401. A live local session is +// revalidated against the cloud before it is reported as logged-in: an account +// whose cloud session no longer exists — most importantly a WITHDRAWN account, +// whose cloud sessions are cascade-deleted — must not keep reporting logged-in +// off the local snapshot, so such a session is expired here on the spot. func (s *Service) handleSession(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(cookieName); err == nil { - if sess := s.sessions.get(c.Value); sess != nil { - resp := map[string]any{ - "logged_in": true, - "expires_at": sess.ExpiresAt.UTC().Format(time.RFC3339), - "engine_connected": s.engineReady(), - // TODO(plan-source): the account's subscription plan. The cloud - // source is undecided (workspace tier vs principal vs billing - // API), so this is a placeholder. Replace this one line with the - // real lookup once the source is settled; the wire contract - // (lowercase string, open value set) stays the same. - "plan": "free", - } - if len(sess.Me) > 0 { - resp["me"] = meWithAvatar(sess.Me) - } - writeJSON(w, http.StatusOK, resp) - return + c, err := r.Cookie(cookieName) + if err != nil { + s.writeLoggedOut(w) + return + } + sess := s.sessions.get(c.Value) + if sess == nil { + s.writeLoggedOut(w) + return + } + + // Revalidate against the cloud. Me() returns a nil principal with a nil error + // ONLY on a 401 — the "this cloud session no longer exists" signal, which a + // withdrawn account produces. Any other error is transient (network / 5xx): + // keep the local session rather than log the owner out over a blip. + if raw, merr := s.cloud.Me(r.Context(), sess.CloudCookie()); merr == nil && raw == nil { + s.log.Info("console: cloud session gone (account withdrawn or revoked) — expiring console login") + s.sessions.delete(sess.ID) + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1}) + s.writeLoggedOut(w) + return + } + + resp := map[string]any{ + "logged_in": true, + "expires_at": sess.ExpiresAt.UTC().Format(time.RFC3339), + "engine_connected": s.engineReady(), + // TODO(plan-source): the account's subscription plan. The cloud source is + // undecided (workspace tier vs principal vs billing API), so this is a + // placeholder. Replace this one line with the real lookup once the source + // is settled; the wire contract (lowercase string, open value set) stays. + "plan": "free", + } + if len(sess.Me) > 0 { + resp["me"] = meWithAvatar(sess.Me) + } + // Owner status (single-admin surface). The owner is the account that first + // claimed the console; any other logged-in account is a soft block — the app + // shows an owner-locked notice instead of the admin surfaces. Naming the owner + // lets that notice say whom to ask. A read failure fails OPEN (is_owner:true): + // the alternative would lock the real owner out over a transient DB error. + isOwner := true + if o, oerr := s.owner.get(); oerr == nil && o != nil { + isOwner = strings.EqualFold(o.Email, emailFromMe(sess.Me)) + if !isOwner { + resp["owner_email"] = o.Email } } + resp["is_owner"] = isOwner + writeJSON(w, http.StatusOK, resp) +} + +// writeLoggedOut emits the single logged-out session body (always 200). +func (s *Service) writeLoggedOut(w http.ResponseWriter) { writeJSON(w, http.StatusOK, map[string]any{"logged_in": false, "engine_connected": s.engineReady()}) } From eab7c0e2273202c7ab04ec90517494398e172bd2 Mon Sep 17 00:00:00 2001 From: Matthew Laszcz Date: Wed, 22 Jul 2026 14:59:39 +0900 Subject: [PATCH 02/24] fix(frontend): require Node >=22.22.0 to match react-router@8 engine constraint react-router@8.2.0 declares engines.node >=22.22.0, so a fresh install on Node 20 fails with EBADENGINE/notsup despite our engines field promising >=20 works. Align the declared engine with what the dependency tree actually requires. Co-Authored-By: Claude Fable 5 --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 5ac9372..93d13d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@10.34.4", "engines": { - "node": ">=20" + "node": ">=22.22.0" }, "scripts": { "dev": "vite", From 2f98e4442ef4e509d9c071000ed9233b1240c3ca Mon Sep 17 00:00:00 2001 From: Matthew Laszcz Date: Fri, 24 Jul 2026 08:22:52 +0900 Subject: [PATCH 03/24] =?UTF-8?q?feat(frontend):=20bilingual=20UI=20?= =?UTF-8?q?=E2=80=94=20static=20en/ko=20locales=20with=20language=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move every user-visible Korean string into src/locales (ko.ts verbatim, en.ts translated). The locale is chosen once at page load (localStorage rc_lang override, else browser language) and exported as L; components read plain constants — no i18n library, no runtime lookup. ko.ts is typed against en.ts's shape so the two languages cannot drift apart silently. Parameterized strings are per-language functions so Korean counters and English word order each read naturally. A globe LanguageToggle (labeled with the language it switches to) sits in both navbars — signed-in and signed-out — and persists + reloads. Tests are pinned to Korean in setup.ts since the suite asserts the original copy; locales.test.ts covers the English side and structural parity. Dev-only fixtures (UITestPage, teamsDummyData) and code comments intentionally keep their Korean. Co-Authored-By: Claude Fable 5 --- .../src/components/auth/OwnerLockedNotice.tsx | 11 +- .../src/components/drawer/MembershipRow.tsx | 5 +- frontend/src/components/elements/Dropdown.tsx | 3 +- .../src/components/elements/Pagination.tsx | 7 +- .../src/components/elements/SearchInput.tsx | 5 +- .../components/navigation/LanguageToggle.tsx | 28 ++ frontend/src/components/navigation/Navbar.tsx | 13 +- .../src/components/navigation/ProfileMenu.tsx | 7 +- .../components/navigation/PublicNavbar.tsx | 22 +- .../src/components/teams/AddMemberModal.tsx | 17 +- .../src/components/teams/CreateTeamModal.tsx | 15 +- .../src/components/teams/DeleteTeamModal.tsx | 33 +- frontend/src/components/teams/OrgChart.tsx | 7 +- .../teams/RemoveMembershipModal.tsx | 13 +- .../src/components/teams/RenameTeamModal.tsx | 5 +- .../teams/RoleChangeConfirmModal.tsx | 7 +- .../src/components/teams/TreeDetailView.tsx | 122 +++--- frontend/src/components/teams/teamOptions.ts | 4 +- .../src/components/toast/ToastContainer.tsx | 3 +- frontend/src/components/tree/TeamTree.tsx | 5 +- frontend/src/components/tree/TreeNode.tsx | 7 +- .../components/update/UpdateFloatingCard.tsx | 19 +- .../users/CancelInvitationModal.tsx | 5 +- .../components/users/InviteMemberModal.tsx | 36 +- .../components/users/MemberDeleteModal.tsx | 13 +- .../components/users/MemberDetailDrawer.tsx | 83 ++-- .../users/MembershipRemoveModal.tsx | 11 +- .../users/RoleChangeConfirmModal.tsx | 9 +- .../users/SessionDeactivateModal.tsx | 5 +- .../src/components/users/invitePreview.ts | 7 +- .../components/workspace/WorkspaceModal.tsx | 44 +- frontend/src/constants/commonConstants.ts | 92 +---- frontend/src/constants/styleConstants.ts | 31 +- .../src/locales/__tests__/locales.test.ts | 49 +++ frontend/src/locales/en.ts | 386 ++++++++++++++++++ frontend/src/locales/index.ts | 37 ++ frontend/src/locales/ko.ts | 373 +++++++++++++++++ frontend/src/pages/LoginPage.tsx | 7 +- frontend/src/pages/SessionsPage.tsx | 35 +- frontend/src/pages/TeamsPage.tsx | 35 +- frontend/src/pages/UsersPage.tsx | 80 ++-- frontend/src/pages/WorkspacePage.tsx | 21 +- frontend/src/test/setup.ts | 6 + frontend/src/utils/username.ts | 5 +- 44 files changed, 1312 insertions(+), 416 deletions(-) create mode 100644 frontend/src/components/navigation/LanguageToggle.tsx create mode 100644 frontend/src/locales/__tests__/locales.test.ts create mode 100644 frontend/src/locales/en.ts create mode 100644 frontend/src/locales/index.ts create mode 100644 frontend/src/locales/ko.ts diff --git a/frontend/src/components/auth/OwnerLockedNotice.tsx b/frontend/src/components/auth/OwnerLockedNotice.tsx index aa14caa..8259ce2 100644 --- a/frontend/src/components/auth/OwnerLockedNotice.tsx +++ b/frontend/src/components/auth/OwnerLockedNotice.tsx @@ -6,6 +6,7 @@ import RuneMark from "@/components/elements/RuneMark"; import PublicNavbar from "@/components/navigation/PublicNavbar"; import { postLogout } from "@/api/authAPIs"; import { BTN_TEXT, PATH_LIST, QUERY_KEYS } from "@/constants/commonConstants"; +import { L } from "@/locales"; /** * OwnerLockedNotice is the soft-block screen shown when a signed-in account is @@ -35,18 +36,18 @@ const OwnerLockedNotice = ({ owner }: { owner?: string }) => {
-

사용할 수 없는 계정

+

{L.auth.ownerLockedTitle}

- 이 콘솔은{" "} + {L.auth.ownerLockedPrefix} {owner ? ( {owner} ) : ( - "다른 계정" + L.auth.ownerFallback )} - 이(가) 관리하고 있어요. + {L.auth.ownerLockedSuffix}
- 이 계정으로는 콘솔을 사용할 수 없습니다. + {L.auth.ownerLockedBody}

+ ); +}; + +export default LanguageToggle; diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index bae937e..cfc8e37 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import RuneMark from "@/components/elements/RuneMark"; import WorkspaceStatus from "@/components/elements/WorkspaceStatus"; +import LanguageToggle from "@/components/navigation/LanguageToggle"; import ProfileMenu from "@/components/navigation/ProfileMenu"; import WorkspaceModal from "@/components/workspace/WorkspaceModal"; import { useSessionQuery } from "@/hooks/queries/useSessionQuery"; @@ -13,6 +14,7 @@ import { PATH_LIST, QUERY_KEYS, } from "@/constants/commonConstants"; +import { L } from "@/locales"; import { useWorkspaceStore } from "@/stores/workspaceStore"; /** @@ -64,21 +66,23 @@ const Navbar = () => { }`} onClick={workspace ? openModal : undefined} > - 워크스페이스 + + {L.workspace.badgeLabel} + {workspace ? ( workspace.orphaned ? ( /* Reinstall detected: the workspace no longer matches this console. Flag it so the badge doesn't read as healthy; clicking opens the modal's 재생성 prompt. */ - 재생성 필요 + {L.workspace.badgeRecreate} ) : workspace.reconnectRequired ? ( /* Data-plane credential expired: the cloud workspace is fine but the local engine link is stale. Flag it (not the healthy pill); clicking opens the modal's 재연결 prompt. */ - 재연결 필요 + {L.workspace.badgeReconnect} ) : ( @@ -89,10 +93,11 @@ const Navbar = () => { className="text-muted-foreground hover:text-foreground cursor-pointer rounded border px-2 py-1 text-xs" onClick={() => navigate(PATH_LIST.workspace)} > - 워크스페이스 없음 + {L.workspace.badgeNone} ) : null}
+ {me && } diff --git a/frontend/src/components/navigation/ProfileMenu.tsx b/frontend/src/components/navigation/ProfileMenu.tsx index ba66f14..d2455c7 100644 --- a/frontend/src/components/navigation/ProfileMenu.tsx +++ b/frontend/src/components/navigation/ProfileMenu.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import TextButton from "@/components/elements/TextButton"; import { BTN_TEXT } from "@/constants/commonConstants"; +import { L } from "@/locales"; interface ProfileMenuProps { // avatar is optional: the backend omits it when the principal has no picture, @@ -59,7 +60,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => { type="button" aria-haspopup="menu" aria-expanded={open} - aria-label="프로필 메뉴" + aria-label={L.profile.menu} className="block cursor-pointer rounded-full" onClick={() => setOpen((v) => !v)} > @@ -68,7 +69,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => { ) : ( 프로필 이미지 setImgError(true)} /> @@ -83,7 +84,7 @@ const ProfileMenu = ({ me, onSignOut }: ProfileMenuProps) => {
{me.email}
-
플랜: Free
+
{L.profile.plan}

{ {BRAND_WORDMARK} - {!onLoginPage && ( - - {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( + {pageWindow(page, totalPages).map((n) => ( ) : null} + {me && } diff --git a/frontend/src/components/teams/AddMemberModal.tsx b/frontend/src/components/teams/AddMemberModal.tsx index 73167f2..1a1a3bc 100644 --- a/frontend/src/components/teams/AddMemberModal.tsx +++ b/frontend/src/components/teams/AddMemberModal.tsx @@ -1,3 +1,4 @@ +import { L } from "@/locales"; import { useState } from "react"; import Button from "@/components/elements/Button"; @@ -6,7 +7,7 @@ import Input from "@/components/elements/Input"; import MemberStatus from "@/components/elements/MemberStatus"; import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; -import { EMAIL_FORMAT_ERROR, EMAIL_PATTERN } from "@/utils/email"; +import { EMAIL_PATTERN } from "@/utils/email"; import { isSubmittableUsername, normalizeUsernameInput, @@ -67,7 +68,7 @@ const AddMemberModal = ({ maxLength={100} value={account} setValue={setAccount} - error={invalidFormat ? EMAIL_FORMAT_ERROR : undefined} + error={invalidFormat ? L.teams.invalidEmail : undefined} /> 0 && !isInvalidFormat && !isDuplicate; const nameError = isInvalidFormat - ? TEAM_NAME_RULE_TEXT + ? L.teams.teamNameRule : trimmed && isDuplicate - ? TEAM_NAME_DUPLICATE_TEXT + ? L.teams.dupName : undefined; return ( @@ -70,7 +69,7 @@ const CreateTeamModal = ({ maxLength={50} value={name} setValue={setName} - hint={TEAM_NAME_RULE_TEXT} + hint={L.teams.teamNameRule} error={nameError} /> 0 && isChanged && !isInvalidFormat && !isDuplicate; const nameError = isInvalidFormat - ? TEAM_NAME_RULE_TEXT + ? L.teams.teamNameRule : isDuplicate - ? TEAM_NAME_DUPLICATE_TEXT + ? L.teams.dupName : undefined; return ( @@ -72,7 +71,7 @@ const RenameTeamModal = ({ maxLength={50} value={name} setValue={setName} - hint={TEAM_NAME_RULE_TEXT} + hint={L.teams.teamNameRule} error={nameError} /> {error && {error}} diff --git a/frontend/src/components/teams/TreeDetailView.tsx b/frontend/src/components/teams/TreeDetailView.tsx index a6d1297..a78fa09 100644 --- a/frontend/src/components/teams/TreeDetailView.tsx +++ b/frontend/src/components/teams/TreeDetailView.tsx @@ -1,3 +1,4 @@ +import { L } from "@/locales"; import { useEffect, useMemo, useState } from "react"; import Button from "@/components/elements/Button"; @@ -43,10 +44,7 @@ import { DEFAULT_PAGE_SIZE, TABLE_HEADERS, } from "@/constants/commonConstants"; -import { - ADD_MEMBER_REASON, - BATCH_REASON_FALLBACK, -} from "@/constants/errorConstants"; +import { ADD_MEMBER_REASON } from "@/constants/errorConstants"; import { NOTICE_TEXT } from "@/constants/noticeConstants"; import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes"; import type { TRoleChange } from "@/types/userTypes"; @@ -247,7 +245,7 @@ const TreeDetailView = ({ toBatchFailureRows( result.failed, accountOf, - () => BATCH_REASON_FALLBACK, + () => L.common.processFailed, ), ); } else { diff --git a/frontend/src/components/users/InviteMemberModal.tsx b/frontend/src/components/users/InviteMemberModal.tsx index cb614c5..0dc1fe0 100644 --- a/frontend/src/components/users/InviteMemberModal.tsx +++ b/frontend/src/components/users/InviteMemberModal.tsx @@ -1,3 +1,4 @@ +import { L } from "@/locales"; import { useRef, useState } from "react"; import Button from "@/components/elements/Button"; @@ -8,7 +9,7 @@ import Notice from "@/components/elements/Notice"; import ModalLayout from "@/components/layout/ModalLayout"; import ModalTable from "@/components/users/ModalTable"; import { buildTeamOptions } from "@/utils/buildTeamOptions"; -import { EMAIL_FORMAT_ERROR, EMAIL_PATTERN } from "@/utils/email"; +import { EMAIL_PATTERN } from "@/utils/email"; import { buildInvitePreview } from "@/utils/invitePreview"; import { isSubmittableUsername, @@ -93,7 +94,7 @@ const InviteMemberModal = ({ const validateEmail = () => { if (email.trim() && !EMAIL_PATTERN.test(email.trim())) - setEmailError(EMAIL_FORMAT_ERROR); + setEmailError(L.teams.invalidEmail); }; /** Team options for one set — sourced from the real team tree (GET diff --git a/frontend/src/constants/commonConstants.ts b/frontend/src/constants/commonConstants.ts index 775d11a..3e972c9 100644 --- a/frontend/src/constants/commonConstants.ts +++ b/frontend/src/constants/commonConstants.ts @@ -1,3 +1,4 @@ +import { L } from "@/locales"; /** BRAND_WORDMARK is the displayed product wordmark, shared by the navbars * (SC-01/SC-03) and the login card heading (SC-01) so the brand string has a * single source of truth. */ @@ -18,60 +19,146 @@ export const DEFAULT_PAGE_SIZE = 10; * scope — those are accessibility strings, not button captions. */ export const BTN_TEXT = { // Generic actions - close: "닫기", - cancel: "취소", - confirm: "확인", - save: "저장", - delete: "삭제하기", - remove: "제거하기", - add: "추가", - create: "생성", - change: "변경하기", - refresh: "새로고침", - retry: "다시 시도", - update: "업데이트", - later: "나중에", + get close() { + return L.btn.close; + }, + get cancel() { + return L.btn.cancel; + }, + get confirm() { + return L.btn.confirm; + }, + get save() { + return L.btn.save; + }, + get delete() { + return L.btn.delete; + }, + get remove() { + return L.btn.remove; + }, + get add() { + return L.btn.add; + }, + get create() { + return L.btn.create; + }, + get change() { + return L.btn.change; + }, + get refresh() { + return L.btn.refresh; + }, + get retry() { + return L.btn.retry; + }, + get update() { + return L.btn.update; + }, + get later() { + return L.btn.later; + }, // Auth / navigation - signOut: "로그아웃", - getStarted: "시작하기", - login: "로그인하기", - home: "홈으로", + get signOut() { + return L.btn.signOut; + }, + get getStarted() { + return L.btn.getStarted; + }, + get login() { + return L.btn.login; + }, + get home() { + return L.btn.home; + }, // Workspace - restart: "재실행", - stop: "중지", - deactivate: "비활성화", - recreate: "삭제 후 재생성", - reconnect: "재연결", + get restart() { + return L.btn.restart; + }, + get stop() { + return L.btn.stop; + }, + get deactivate() { + return L.btn.deactivate; + }, + get recreate() { + return L.btn.recreate; + }, + get reconnect() { + return L.btn.reconnect; + }, // Teams - createTeam: "새 팀 만들기", - createGroup: "팀 생성하기", - rename: "이름 변경", - deleteTeam: "팀 삭제하기", - addMember: "멤버 추가하기", - addTeam: "팀 추가하기", + get createTeam() { + return L.btn.createTeam; + }, + get createGroup() { + return L.btn.createGroup; + }, + get rename() { + return L.btn.rename; + }, + get deleteTeam() { + return L.btn.deleteTeam; + }, + get addMember() { + return L.btn.addMember; + }, + get addTeam() { + return L.btn.addTeam; + }, // Members / invitations - invite: "초대하기", - inviteMember: "멤버 초대하기", - sendInvitation: "초대 전송", - resendInvitationCode: "초대 코드 재전송", - cancelInvitation: "초대 취소", - cancelAction: "취소하기", - addTeamRole: "+ 팀/권한 추가", + get invite() { + return L.btn.invite; + }, + get inviteMember() { + return L.btn.inviteMember; + }, + get sendInvitation() { + return L.btn.sendInvitation; + }, + get resendInvitationCode() { + return L.btn.resendInvitationCode; + }, + get cancelInvitation() { + return L.btn.cancelInvitation; + }, + get cancelAction() { + return L.btn.cancelAction; + }, + get addTeamRole() { + return L.btn.addTeamRole; + }, removeRow: "✕", - updateChanges: "변경사항 업데이트", - resetChanges: "변경사항 초기화", - deactivateSession: "세션 비활성화", - deleteMember: "멤버 삭제", + get updateChanges() { + return L.btn.updateChanges; + }, + get resetChanges() { + return L.btn.resetChanges; + }, + get deactivateSession() { + return L.btn.deactivateSession; + }, + get deleteMember() { + return L.btn.deleteMember; + }, } as const; /** PAGE_TITLES is the page/section vocabulary — shared by the main nav, * each page's
, and the workspace modal title, so the * same screen is never named two different things. */ export const PAGE_TITLES = { - teams: "팀 관리", - users: "멤버 관리", - sessions: "세션 기록", - workspace: "워크스페이스 관리", + get teams() { + return L.nav.teams; + }, + get users() { + return L.nav.users; + }, + get sessions() { + return L.nav.sessions; + }, + get workspace() { + return L.modal.workspaceManage; + }, } as const; /** MODAL_TITLES is the single source of truth for ModalLayout titles across @@ -81,23 +168,45 @@ export const PAGE_TITLES = { export const MODAL_TITLES = { // Workspace workspaceManage: PAGE_TITLES.workspace, - workspaceDelete: "워크스페이스 삭제", - workspaceOrphaned: "워크스페이스 재생성 필요", - workspaceReconnect: "워크스페이스 재연결 필요", + get workspaceDelete() { + return L.modal.workspaceDelete; + }, + get workspaceOrphaned() { + return L.modal.workspaceOrphaned; + }, + get workspaceReconnect() { + return L.modal.workspaceReconnect; + }, // Teams - createTeam: "새 팀 만들기", - renameTeam: "팀 이름 변경", - deleteTeam: (teamName: string) => `팀 삭제 — ${teamName}`, - addMember: (teamName: string) => `멤버 추가 — ${teamName}`, - batchFailure: "일부 항목을 처리하지 못했습니다", + get createTeam() { + return L.btn.createTeam; + }, + get renameTeam() { + return L.modal.renameTeam; + }, + deleteTeam: (teamName: string) => L.modal.deleteTeam(teamName), + addMember: (teamName: string) => L.modal.addMember(teamName), + get batchFailure() { + return L.modal.batchFailure; + }, // Members / roles / invitations - roleChange: "권한 변경", - removeMembership: "멤버십 제거", - inviteMember: "멤버 초대", - cancelInvitation: "초대 취소", - deactivateSession: "세션 비활성화", - deleteMemberSingle: (account: string) => `멤버 삭제 — ${account}`, - deleteMemberBulk: (count: number) => `멤버 삭제 (${count}명)`, + get roleChange() { + return L.modal.roleChange; + }, + get removeMembership() { + return L.modal.removeMembership; + }, + get inviteMember() { + return L.modal.inviteMember; + }, + get cancelInvitation() { + return L.btn.cancelInvitation; + }, + get deactivateSession() { + return L.btn.deactivateSession; + }, + deleteMemberSingle: (account: string) => L.modal.deleteMemberSingle(account), + deleteMemberBulk: (count: number) => L.modal.deleteMemberBulk(count), } as const; export const PATH_LIST = { @@ -110,61 +219,120 @@ export const PATH_LIST = { uiTest: "/ui-test", } as const; +/* `title` is a getter so the sidebar labels re-resolve on a live language + switch — reading PAGE_TITLES.* eagerly would freeze the value here. */ export const NAV_LIST = [ - { title: PAGE_TITLES.teams, url: PATH_LIST.teams }, - { title: PAGE_TITLES.users, url: PATH_LIST.users }, - { title: PAGE_TITLES.sessions, url: PATH_LIST.sessions }, + { + get title() { + return PAGE_TITLES.teams; + }, + url: PATH_LIST.teams, + }, + { + get title() { + return PAGE_TITLES.users; + }, + url: PATH_LIST.users, + }, + { + get title() { + return PAGE_TITLES.sessions; + }, + url: PATH_LIST.sessions, + }, ] as const; /** TABLE_HEADERS is the column-header copy shared across the list tables, * the modal tables, and the sort-option labels that mirror a column. */ export const TABLE_HEADERS = { - memberName: "멤버 이름", - memberStatus: "멤버 상태", - team: "팀", - teamWithRole: "팀 (권한)", - role: "권한", + get memberName() { + return L.common.memberName; + }, + get memberStatus() { + return L.common.memberStatus; + }, + get team() { + return L.common.team; + }, + get teamWithRole() { + return L.members.teamRoleHeader; + }, + get role() { + return L.common.role; + }, /* TreeDetailView's member table says 역할 while every other role column says 권한 — kept verbatim pending a copy decision; unifying is a one-line change here once decided. */ - roleAlt: "역할", - roleChange: "권한 변경", - joinedAt: "합류일", + get roleAlt() { + return L.teams.roleHeader; + }, + get roleChange() { + return L.modal.roleChange; + }, + get joinedAt() { + return L.teams.joinedAt; + }, account: "account", - reason: "사유", - user: "사용자", - issuedAt: "발급 시간", - lastAccess: "최근 접속 시간", + get reason() { + return L.members.reason; + }, + get user() { + return L.common.users; + }, + get issuedAt() { + return L.members.issuedAt; + }, + get lastAccess() { + return L.members.lastAccessedAt; + }, } as const; /** Form-field copy shared by the invite (SC-12) and add-member (SC-06) * forms — labels are also how tests and screen readers find the fields. */ export const INPUT_LABELS = { - emailAccount: "이메일 (account)", - username: "사용자 이름 (username)", + get emailAccount() { + return L.teams.emailLabel; + }, + get username() { + return L.teams.usernameLabel; + }, } as const; export const PLACEHOLDERS = { - selectTeam: "팀 선택", - selectRole: "권한 선택", + get selectTeam() { + return L.teams.selectTeam; + }, + get selectRole() { + return L.teams.selectRole; + }, /** Team picker when every team is already joined (SC-13 add row). */ - noAddableTeam: "추가할 팀 없음", + get noAddableTeam() { + return L.members.noTeamsToAdd; + }, emailExample: "user@corp.com", - username: "사용자 이름", + get username() { + return L.teams.usernamePlaceholder; + }, } as const; /** Icon/control aria-labels used on more than one screen — centralized so * assistive tech hears the same name everywhere (they had already drifted: * "전체 선택" vs "전체선택"). */ export const ARIA_LABELS = { - selectAll: "전체 선택", - sort: "정렬", + get selectAll() { + return L.common.selectAll; + }, + get sort() { + return L.common.sort; + }, } as const; /** Shared Feedback copy — per-screen titles stay local; only the copy that * repeats across screens lives here. */ export const FEEDBACK_TEXT = { - refreshRetry: "새로고침 후 다시 시도해 주세요.", + get refreshRetry() { + return L.common.refreshRetry; + }, } as const; export const QUERY_KEYS = { diff --git a/frontend/src/constants/errorConstants.ts b/frontend/src/constants/errorConstants.ts index 2e5d32b..78a47b4 100644 --- a/frontend/src/constants/errorConstants.ts +++ b/frontend/src/constants/errorConstants.ts @@ -1,46 +1,59 @@ +import { L } from "@/locales"; import { ERROR_CODES } from "@/constants/apiConstants"; /** - * Backend error code → user-facing Korean copy, shared by every screen that - * surfaces the shared error envelope (parseErrorCode). The same code can read + * Backend error code → user-facing copy, shared by every screen that surfaces + * the shared error envelope (parseErrorCode). The same code can read * differently per flow (e.g. USER_NOT_FOUND during add vs batch), so maps are * grouped by context rather than merged into one — pick the map that matches * the flow. For unmapped codes each call site picks its own fallback: the * generic retry copy, or the raw backend code itself where that diagnostic * detail is worth showing (member removal / user delete failure modals). + * + * Each entry is a getter so it re-resolves against the active language on a + * live switch — a plain `L.*` value would freeze at module-eval time. */ -/** Duplicate-name copy — shared by the server reason map and the client-side - duplicate check in the create/rename team modals (must stay identical). */ -export const TEAM_NAME_DUPLICATE_TEXT = - "같은 상위 팀에 동일한 이름이 이미 있습니다."; - /** Team CRUD failures (SC-06/07 — create · rename · delete). */ export const TEAM_REASON: Record = { - [ERROR_CODES.TEAM_NAME_DUPLICATE]: TEAM_NAME_DUPLICATE_TEXT, - [ERROR_CODES.TEAM_NAME_INVALID]: "팀 이름 형식이 올바르지 않습니다.", - [ERROR_CODES.TEAM_HAS_CHILDREN]: "하위 팀이 있어 삭제할 수 없습니다.", + get [ERROR_CODES.TEAM_NAME_DUPLICATE]() { + return L.teams.dupName; + }, + get [ERROR_CODES.TEAM_NAME_INVALID]() { + return L.teams.invalidTeamName; + }, + get [ERROR_CODES.TEAM_HAS_CHILDREN]() { + return L.teams.cannotDeleteHasChildren; + }, }; /** Per-target failure reasons from the batch endpoints (bulk role change, membership removal, user delete) — listed in MemberBatchFailureModal. */ export const BATCH_REASON: Record = { - [ERROR_CODES.USER_NOT_FOUND]: "사용자를 찾을 수 없습니다", - [ERROR_CODES.NOT_TEAM_MEMBER]: "팀 멤버가 아닙니다", - [ERROR_CODES.TEAM_NOT_FOUND]: "팀을 찾을 수 없습니다", + get [ERROR_CODES.USER_NOT_FOUND]() { + return L.teams.userNotFound; + }, + get [ERROR_CODES.NOT_TEAM_MEMBER]() { + return L.teams.notTeamMember; + }, + get [ERROR_CODES.TEAM_NOT_FOUND]() { + return L.members.teamNotFound; + }, }; -/** Generic retry copy for an unmapped batch code (e.g. a transient - INTERNAL). Used by the role-change flows; the removal/delete failure - modals instead surface the raw code as a diagnostic hint. */ -export const BATCH_REASON_FALLBACK = "처리에 실패했습니다. 다시 시도해 주세요."; - /** Add-member flow failures (SC-06 팀에 멤버 추가) — the add context words the same codes differently (USER_NOT_FOUND = unregistered account). */ export const ADD_MEMBER_REASON: Record = { - [ERROR_CODES.ALREADY_TEAM_MEMBER]: "이미 초대된 사용자입니다.", - [ERROR_CODES.USER_NOT_FOUND]: "등록되지 않은 계정입니다.", - [ERROR_CODES.CANNOT_INVITE_ADMIN]: "콘솔 관리자 계정은 추가할 수 없습니다.", - [ERROR_CODES.MAIL_UPSTREAM_ERROR]: - "초대 코드 전송에 실패했습니다. 다시 시도해 주세요.", + get [ERROR_CODES.ALREADY_TEAM_MEMBER]() { + return L.teams.alreadyInvited; + }, + get [ERROR_CODES.USER_NOT_FOUND]() { + return L.teams.notRegistered; + }, + get [ERROR_CODES.CANNOT_INVITE_ADMIN]() { + return L.teams.cannotAddAdmin; + }, + get [ERROR_CODES.MAIL_UPSTREAM_ERROR]() { + return L.teams.inviteSendFailed; + }, }; diff --git a/frontend/src/constants/noticeConstants.ts b/frontend/src/constants/noticeConstants.ts index f3ef8b5..7ca84df 100644 --- a/frontend/src/constants/noticeConstants.ts +++ b/frontend/src/constants/noticeConstants.ts @@ -1,3 +1,4 @@ +import { L } from "@/locales"; import { MODAL_TITLES } from "@/constants/commonConstants"; /** @@ -10,55 +11,103 @@ import { MODAL_TITLES } from "@/constants/commonConstants"; */ export const NOTICE_TEXT = { resendInvitation: { - title: "초대 코드 재전송", - success: "초대 코드를 재전송했습니다.", - failure: "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.", + get title() { + return L.btn.resendInvitationCode; + }, + get success() { + return L.members.inviteCodeResent; + }, + get failure() { + return L.members.resendCodeFailed; + }, /** Per-account reason row in the batch-failure modal. */ - failedReason: "재전송 실패", + get failedReason() { + return L.members.resendFailedShort; + }, }, addMembership: { - title: "팀 추가", - success: "팀에 추가되었습니다.", - alreadyMember: "이미 소속된 팀입니다.", - failure: "팀 추가에 실패했습니다. 다시 시도해 주세요.", + get title() { + return L.members.addTeamTitle; + }, + get success() { + return L.members.addedToTeam; + }, + get alreadyMember() { + return L.members.alreadyTeamMember; + }, + get failure() { + return L.members.addTeamFailed; + }, }, /* Role-change and remove-failure results render INSIDE RoleChangeConfirmModal/MembershipRemoveModal (SC-06 E-1/E-2) — only the full-success removal toast goes through showNotice. */ removeMembership: { title: MODAL_TITLES.removeMembership, - success: "멤버십이 제거되었습니다.", + get success() { + return L.teams.membershipsRemoved; + }, }, deactivateSession: { title: MODAL_TITLES.deactivateSession, - success: "세션을 비활성화했습니다.", - alreadyExpired: "이미 만료된 세션입니다.", - failure: "세션 비활성화에 실패했습니다. 다시 시도해 주세요.", + get success() { + return L.members.sessionDeactivated; + }, + get alreadyExpired() { + return L.members.sessionAlreadyExpired; + }, + get failure() { + return L.members.deactivateFailed; + }, }, cancelInvitation: { title: MODAL_TITLES.cancelInvitation, - success: "초대를 취소했습니다.", - nothingToCancel: "취소할 초대가 없습니다.", - failure: "초대 취소에 실패했습니다. 다시 시도해 주세요.", + get success() { + return L.members.invitationCanceled; + }, + get nothingToCancel() { + return L.members.noInvitationToCancel; + }, + get failure() { + return L.members.cancelInvitationFailed; + }, }, createTeam: { - title: "팀 생성", - success: "팀이 생성되었습니다.", + get title() { + return L.teams.createTeamTitle; + }, + get success() { + return L.teams.teamCreated; + }, }, renameTeam: { title: MODAL_TITLES.renameTeam, - success: "팀 이름이 변경되었습니다.", + get success() { + return L.teams.teamRenamed; + }, }, deleteTeam: { - title: "팀 삭제", - success: "팀이 삭제되었습니다.", + get title() { + return L.teams.deleteTeamTitle; + }, + get success() { + return L.teams.teamDeleted; + }, }, addTeamMember: { - title: "멤버 추가", - success: "멤버를 추가했습니다.", + get title() { + return L.teams.addMemberTitle; + }, + get success() { + return L.teams.memberAdded; + }, }, deleteMember: { - title: "멤버 삭제", - success: "멤버를 삭제했습니다.", + get title() { + return L.btn.deleteMember; + }, + get success() { + return L.members.membersDeleted; + }, }, } as const; diff --git a/frontend/src/constants/styleConstants.ts b/frontend/src/constants/styleConstants.ts index 9858dce..1604d86 100644 --- a/frontend/src/constants/styleConstants.ts +++ b/frontend/src/constants/styleConstants.ts @@ -1,3 +1,4 @@ +import { L } from "@/locales"; /** * Style variant maps for shared element components (envector pattern). * Visual values are translated from UIKIT modules/rune-ui-buttons and @@ -99,27 +100,41 @@ export const MODAL_STYLE_VAR = { footer: "flex w-full items-center gap-4", } as const; -/* Session chips — the only status a list view shows. The satisfies clause - keys this map to the status union: adding/renaming a status value is a - compile error here until the label map follows. */ +/* Status labels come from the locale table; colors are style concerns and + stay here. `label` is a getter so it re-resolves against the active + language on a live switch — a captured L.* value would freeze at + module-eval time. The satisfies clause still keys each map to its status + union: adding/renaming a status value is a compile error here until the + label map follows. */ +const statusVar = (label: () => string, color: string): TStatusStyle => ({ + get label() { + return label(); + }, + color, +}); + +/* Session chips — the only status a list view shows. */ export const MEMBER_STATUS_VAR = { - online: { label: "온라인", color: "text-mint" }, - offline: { label: "오프라인", color: "text-faint" }, -} as const satisfies Record; + online: statusVar(() => L.status.member.online, "text-mint"), + offline: statusVar(() => L.status.member.offline, "text-faint"), +} satisfies Record; /* Invitation-status labels — shown only in the member detail drawer. */ export const INVITATION_STATUS_VAR = { - invite_pending: { label: "초대 수락 대기", color: "text-warning" }, - invite_expired: { label: "초대 코드 만료", color: "text-faint" }, - invite_redeemed: { label: "초대 코드 사용됨", color: "text-accent-blue" }, -} as const satisfies Record; + invite_pending: statusVar(() => L.status.invitation.pending, "text-warning"), + invite_expired: statusVar(() => L.status.invitation.expired, "text-faint"), + invite_redeemed: statusVar( + () => L.status.invitation.redeemed, + "text-accent-blue", + ), +} satisfies Record; export const WORKSPACE_STATUS_VAR = { - provisioning: { label: "생성 중", color: "text-warning" }, - running: { label: "실행 중", color: "text-mint" }, - stopping: { label: "정지 중", color: "text-warning" }, - stopped: { label: "정지", color: "text-faint" }, - starting: { label: "재실행 중", color: "text-warning" }, - deleting: { label: "삭제 중", color: "text-warning" }, - error: { label: "사용 불가", color: "text-negative" }, -} as const satisfies Record; + provisioning: statusVar(() => L.status.workspace.provisioning, "text-warning"), + running: statusVar(() => L.status.workspace.running, "text-mint"), + stopping: statusVar(() => L.status.workspace.stopping, "text-warning"), + stopped: statusVar(() => L.status.workspace.stopped, "text-faint"), + starting: statusVar(() => L.status.workspace.starting, "text-warning"), + deleting: statusVar(() => L.status.workspace.deleting, "text-warning"), + error: statusVar(() => L.status.workspace.error, "text-negative"), +} satisfies Record; diff --git a/frontend/src/constants/teamConstants.ts b/frontend/src/constants/teamConstants.ts index ba95277..8e8e9f5 100644 --- a/frontend/src/constants/teamConstants.ts +++ b/frontend/src/constants/teamConstants.ts @@ -4,8 +4,6 @@ import type { TDropdownOption } from "@/types/commonTypes"; /** Team name rule: digits, Hangul, Latin letters, and - _ only. */ export const TEAM_NAME_PATTERN = /^[0-9A-Za-z가-힣_-]+$/; -export const TEAM_NAME_RULE_TEXT = - "숫자·한글·영어와 - _ 만 사용할 수 있습니다."; /** Grantable member roles (Admin is console-account only — API §0). */ export const ROLE_OPTIONS: TDropdownOption[] = [ diff --git a/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts index d6b5319..45beb01 100644 --- a/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts +++ b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts @@ -1,3 +1,4 @@ +import { L } from "@/locales"; import { act, renderHook } from "@testing-library/react"; import { describe, expect, it } from "vitest"; @@ -5,7 +6,6 @@ import { toBatchFailureRows, useBatchFailureModal, } from "@/hooks/useBatchFailureModal"; -import { BATCH_REASON_FALLBACK } from "@/constants/errorConstants"; describe("useBatchFailureModal", () => { it("starts closed and opens with the given rows", () => { @@ -32,7 +32,7 @@ describe("toBatchFailureRows", () => { const rows = toBatchFailureRows( failed, (id) => `acct-${id}`, - () => BATCH_REASON_FALLBACK, + () => L.common.processFailed, ); expect(rows[0]).toEqual({ account: "acct-u1", @@ -40,7 +40,7 @@ describe("toBatchFailureRows", () => { }); expect(rows[1]).toEqual({ account: "acct-u2", - reason: BATCH_REASON_FALLBACK, + reason: L.common.processFailed, }); }); diff --git a/frontend/src/hooks/useMembershipDrafts.ts b/frontend/src/hooks/useMembershipDrafts.ts index 3407273..7ceda28 100644 --- a/frontend/src/hooks/useMembershipDrafts.ts +++ b/frontend/src/hooks/useMembershipDrafts.ts @@ -1,3 +1,4 @@ +import { L } from "@/locales"; import { useState } from "react"; import { @@ -10,7 +11,6 @@ import { useNoticeStore } from "@/state/store/noticeStore"; import { buildTeamOptions } from "@/utils/buildTeamOptions"; import { getTeamDescendantIds } from "@/utils/teamHierarchy"; import { ERROR_CODES } from "@/constants/apiConstants"; -import { BATCH_REASON_FALLBACK } from "@/constants/errorConstants"; import { NOTICE_TEXT } from "@/constants/noticeConstants"; import type { TBatchResult, TTeamTree } from "@/types/teamTypes"; import type { TUserListItem } from "@/types/userTypes"; @@ -170,7 +170,7 @@ export const useMembershipDrafts = ({ toBatchFailureRows( result.failed, teamNameOf, - () => BATCH_REASON_FALLBACK, + () => L.common.processFailed, ), ); } @@ -207,7 +207,7 @@ export const useMembershipDrafts = ({ toBatchFailureRows( result.failed, teamNameOf, - () => BATCH_REASON_FALLBACK, + () => L.common.processFailed, ), ); } diff --git a/frontend/src/utils/email.ts b/frontend/src/utils/email.ts index 6087511..08af41e 100644 --- a/frontend/src/utils/email.ts +++ b/frontend/src/utils/email.ts @@ -4,4 +4,3 @@ export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export const EMAIL_FORMAT_ERROR = "올바른 이메일 형식이 아닙니다."; diff --git a/frontend/src/utils/invitePreview.ts b/frontend/src/utils/invitePreview.ts index c6d2832..3b5795c 100644 --- a/frontend/src/utils/invitePreview.ts +++ b/frontend/src/utils/invitePreview.ts @@ -1,3 +1,4 @@ +import { L } from "@/locales"; import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy"; import type { TTeamTree } from "@/types/teamTypes"; import type { TInviteSet } from "@/types/userTypes"; @@ -36,7 +37,9 @@ export const buildInvitePreview = ( teamId: set.teamId, teamName: getTeamName(teams, set.teamId), role: set.role, - reason: "직접 지정", + get reason() { + return L.members.directlyAssigned; + }, indent: false, }); for (const descId of getTeamDescendantIds(teams, set.teamId)) { From 086fc0876286d39caabf8dac7a4f6a23614384a3 Mon Sep 17 00:00:00 2001 From: Matthew Laszcz Date: Thu, 13 Aug 2026 14:27:01 +0900 Subject: [PATCH 20/24] i18n(phase2b): localize component-level inline strings across the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the re-integration: every user-visible Korean string in the components/pages now reads from the locale tables, so a live language switch turns the entire UI English (not just the shared chrome). - 25 files auto-localized (JSX text, attributes, string props) via the Korean→key reverse index from ko.ts - interpolated strings wired to the locale's function keys (footers, aria-labels, confirm-dialog bodies, member-count headings, team meta, invite notices with the embedded status chip) - inlined the remaining frozen module-const message captures so they re-resolve live (send/remove/role/delete failure copy) Only code comments and regex character classes retain Korean. tsc clean, 333/333 tests pass, build succeeds. --- frontend/src/components/elements/Dropdown.tsx | 2 +- .../src/components/elements/Pagination.tsx | 5 ++- .../components/elements/ToastContainer.tsx | 3 +- frontend/src/components/navigation/Navbar.tsx | 2 +- .../src/components/table/TableLoadingRow.tsx | 4 +- .../src/components/teams/AddMemberModal.tsx | 8 ++-- .../src/components/teams/CreateTeamModal.tsx | 16 ++++---- .../src/components/teams/DeleteTeamModal.tsx | 31 +++++++------- frontend/src/components/teams/OrgChart.tsx | 2 +- .../src/components/teams/RenameTeamModal.tsx | 8 ++-- frontend/src/components/teams/TeamCard.tsx | 9 +++- .../src/components/teams/TeamMembersTable.tsx | 12 +++--- .../components/teams/TeamMembersToolbar.tsx | 3 +- .../src/components/teams/TreeDetailView.tsx | 16 ++++---- frontend/src/components/tree/TeamTree.tsx | 5 ++- frontend/src/components/tree/TreeNode.tsx | 7 +++- .../components/update/UpdateFloatingCard.tsx | 19 ++++----- .../users/CancelInvitationModal.tsx | 5 ++- .../components/users/InviteMemberModal.tsx | 29 ++++++------- .../components/users/MemberDeleteModal.tsx | 13 +++--- .../components/users/MemberDetailDrawer.tsx | 9 ++-- .../users/MembershipRemoveModal.tsx | 13 ++---- .../src/components/users/MembershipRow.tsx | 7 +++- .../components/users/MembershipSection.tsx | 7 ++-- .../users/RoleChangeConfirmModal.tsx | 12 +++--- .../users/SessionDeactivateModal.tsx | 5 ++- frontend/src/components/users/UserRow.tsx | 3 +- .../src/components/users/UsersToolbar.tsx | 15 ++++--- .../components/workspace/WorkspaceModal.tsx | 41 +++++++++---------- frontend/src/constants/commonConstants.ts | 1 + frontend/src/constants/errorConstants.ts | 2 +- frontend/src/constants/noticeConstants.ts | 2 +- frontend/src/constants/styleConstants.ts | 7 +++- frontend/src/constants/teamConstants.ts | 1 - .../__tests__/useBatchFailureModal.test.ts | 2 +- frontend/src/hooks/useMembershipDrafts.ts | 2 +- frontend/src/hooks/useTeamCrud.ts | 7 ++-- .../src/locales/__tests__/locales.test.ts | 8 +--- frontend/src/locales/en.ts | 6 ++- frontend/src/locales/ko.ts | 12 ++++-- frontend/src/main.tsx | 2 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/SessionsPage.tsx | 11 ++--- frontend/src/pages/TeamsPage.tsx | 19 +++++---- frontend/src/pages/UsersPage.tsx | 21 +++++----- frontend/src/pages/WorkspacePage.tsx | 17 ++++---- frontend/src/test/setup.ts | 6 +-- frontend/src/utils/email.ts | 1 - frontend/src/utils/invitePreview.ts | 8 ++-- frontend/src/utils/username.ts | 1 - 50 files changed, 233 insertions(+), 216 deletions(-) diff --git a/frontend/src/components/elements/Dropdown.tsx b/frontend/src/components/elements/Dropdown.tsx index dc41583..5bcf54d 100644 --- a/frontend/src/components/elements/Dropdown.tsx +++ b/frontend/src/components/elements/Dropdown.tsx @@ -4,8 +4,8 @@ import { createPortal } from "react-dom"; import { ChevronDown } from "lucide-react"; import { cn } from "@/utils/cn"; -import { L } from "@/locales"; import type { TDropdownOption } from "@/types/commonTypes"; +import { L } from "@/locales"; const styles = { field: "grid w-full min-w-0 gap-2", diff --git a/frontend/src/components/elements/Pagination.tsx b/frontend/src/components/elements/Pagination.tsx index 11d6b6c..3d30535 100644 --- a/frontend/src/components/elements/Pagination.tsx +++ b/frontend/src/components/elements/Pagination.tsx @@ -41,7 +41,10 @@ const Pagination = ({ className, }: PaginationProps) => { return ( -