diff --git a/.github/assets/repo-badges.svg b/.github/assets/repo-badges.svg
index ec0cb90..9f90216 100644
--- a/.github/assets/repo-badges.svg
+++ b/.github/assets/repo-badges.svg
@@ -1,7 +1,7 @@
-
- Self-hosted console, Go and React, Apache 2.0, Release v1.0.5
+
+ Self-hosted console, Go and React, Apache 2.0, Release v1.1.0
DEPLOYMENT Self-hosted
STACK Go + React
LICENSE Apache 2.0
- RELEASE v1.0.5
+ RELEASE v1.1.0
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 74e89b6..8aa1caa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,4 +100,6 @@ buf/
# Embedded frontend build output (populated by `mise run fe:build`, then baked
# into the runeconsole binary via go:embed). Keep the .gitkeep placeholder.
/internal/console/webdist/*
-!/internal/console/webdist/.gitkeep
\ No newline at end of file
+!/internal/console/webdist/.gitkeep
+# Local vault runtime artifacts (runevault binary is never committed)
+vault/bin/
diff --git a/README.md b/README.md
index 334bd77..27c2429 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
diff --git a/frontend/KOREAN_TO_ENGLISH.md b/frontend/KOREAN_TO_ENGLISH.md
new file mode 100644
index 0000000..c88ceb5
--- /dev/null
+++ b/frontend/KOREAN_TO_ENGLISH.md
@@ -0,0 +1,397 @@
+# Console UI — Korean → English Inventory
+
+Working document for the `UI-english-versions` branch. Swept from `frontend/src`
+(55 files, test files excluded). Edit the **Proposed English** column as needed,
+then we apply the changes file by file.
+
+Conventions used in proposals:
+- Buttons / headings / modal titles: Title Case. Messages / hints: sentence case.
+- `{var}` marks an interpolated value; "(restructure)" flags strings whose Korean
+ particle logic (이/가, 은/는) or word order must be rearranged in English.
+- Korean-only code comments are listed at the end (translate optionally).
+
+**Highest-leverage first:** ~60 strings live in two constants files and cover
+most buttons, modal titles, nav items, and status badges across the app.
+
+---
+
+## 1. Shared constants (`constants/commonConstants.ts`)
+
+### BTN_TEXT
+
+| Location | Korean | Proposed English | Approved |
+|---|---|---|---|
+| `:16 (close)` | 닫기 | Close | ✅ |
+| `:17 (cancel)` | 취소 | Cancel | ✅ |
+| `:18 (confirm)` | 확인 | Confirm | ✅ |
+| `:19 (save)` | 저장 | Save | ✅ |
+| `:20 (delete)` | 삭제하기 | Delete | ✅ |
+| `:21 (remove)` | 제거하기 | Remove | ✅ |
+| `:22 (add)` | 추가 | Add | ✅ |
+| `:23 (create)` | 생성 | Create | ✅ |
+| `:24 (change)` | 변경하기 | Change | ✅ |
+| `:25 (refresh)` | 새로고침 | Refresh | ✅ |
+| `:26 (retry)` | 다시 시도 | Retry | ✅ |
+| `:27 (update)` | 업데이트 | Update | ✅ |
+| `:28 (later)` | 나중에 | Later | ✅ |
+| `:30 (signOut)` | 로그아웃 | Sign Out | ✅ |
+| `:31 (getStarted)` | 시작하기 | Get Started | ✅ |
+| `:32 (login)` | 로그인하기 | Log In | ✅ |
+| `:33 (home)` | 홈으로 | Go to Home | ❌ needs rework |
+| `:35 (restart)` | 재실행 | Restart | ✅ |
+| `:36 (stop)` | 중지 | Stop | ✅ |
+| `:37 (deactivate)` | 비활성화 | Deactivate | ✅ |
+| `:38 (recreate)` | 삭제 후 재생성 | Delete and Recreate | ✅ |
+| `:39 (reconnect)` | 재연결 | Reconnect | ✅ |
+| `:41 (createTeam)` | 새 팀 만들기 | Create New Team | ❌ needs rework |
+| `:42 (createGroup)` | 팀 생성하기 | Create Team | ✅ |
+| `:43 (rename)` | 이름 변경 | Rename | ✅ |
+| `:44 (deleteTeam)` | 팀 삭제하기 | Delete Team | ✅ |
+| `:45 (addMember)` | 멤버 추가하기 | Add Member | ✅ |
+| `:46 (addTeam)` | 팀 추가하기 | Add Team | ✅ |
+| `:48 (invite)` | 초대하기 | Invite | ✅ |
+| `:49 (inviteMember)` | 멤버 초대하기 | Invite Member | ✅ |
+| `:50 (sendInvitation)` | 초대 전송 | Send Invitation | ✅ |
+| `:51 (resendInvitationCode)` | 초대 코드 재전송 | Resend Invitation Code | ✅ |
+| `:52 (cancelInvitation)` | 초대 취소 | Cancel Invitation | ✅ |
+| `:53 (cancelAction)` | 취소하기 | Cancel | ✅ |
+| `:54 (addTeamRole)` | + 팀/권한 추가 | + Add Team/Role | ✅ |
+| `:56 (updateChanges)` | 변경사항 업데이트 | Update Changes | ✅ |
+| `:57 (resetChanges)` | 변경사항 초기화 | Reset Changes | ✅ |
+| `:58 (deactivateSession)` | 세션 비활성화 | Deactivate Session | ✅ |
+| `:59 (deleteMember)` | 멤버 삭제 | Delete Member | ✅ |
+
+### MODAL_TITLES
+
+| Location | Korean | Proposed English | Approved |
+|---|---|---|---|
+| `:68 (workspaceManage)` | 워크스페이스 관리 | Manage Workspace | ✅ |
+| `:69 (workspaceDelete)` | 워크스페이스 삭제 | Delete Workspace | ✅ |
+| `:70 (workspaceOrphaned)` | 워크스페이스 재생성 필요 | Workspace Recreation Required | ✅ |
+| `:71 (workspaceReconnect)` | 워크스페이스 재연결 필요 | Workspace Reconnection Required | ✅ |
+| `:73 (createTeam)` | 새 팀 만들기 | Create New Team | ✅ |
+| `:74 (renameTeam)` | 팀 이름 변경 | Rename Team | ✅ |
+| `:75 (deleteTeam)` | 팀 삭제 — {teamName} | Delete Team — {teamName} | ✅ |
+| `:76 (addMember)` | 멤버 추가 — {teamName} | Add Member — {teamName} | ✅ |
+| `:77 (batchFailure)` | 일부 항목을 처리하지 못했습니다 | Some items could not be processed | ✅ |
+| `:79 (roleChange)` | 권한 변경 | Change Role | ✅ |
+| `:80 (removeMembership)` | 멤버십 제거 | Remove Membership | ✅ |
+| `:81 (inviteMember)` | 멤버 초대 | Invite Member | ✅ |
+| `:82 (cancelInvitation)` | 초대 취소 | Cancel Invitation | ✅ |
+| `:83 (deactivateSession)` | 세션 비활성화 | Deactivate Session | ✅ |
+| `:84 (deleteMemberSingle)` | 멤버 삭제 — {account} | Delete Member — {account} | ✅ |
+| `:85 (deleteMemberBulk)` | 멤버 삭제 ({count}명) | Delete Members ({count}) (restructure) | ✅ |
+
+### NAV_LIST
+
+| Location | Korean | Proposed English |
+|---|---|---|
+| `:99` | 팀 관리 | Team Management |
+| `:100` | 멤버 관리 | Member Management |
+| `:101` | 세션 기록 | Session History |
+
+## 2. Status badges (`constants/styleConstants.ts`)
+
+| Location | Korean | Proposed English | Approved |
+|---|---|---|---|
+| `:84 (MEMBER_STATUS_VAR.online)` | 온라인 | Online | |
+| `:85 (MEMBER_STATUS_VAR.offline)` | 오프라인 | Offline | |
+| `:90 (INVITATION_STATUS_VAR.invite_pending)` | 초대 수락 대기 | Invitation Pending | ✅ |
+| `:91 (INVITATION_STATUS_VAR.invite_expired)` | 초대 코드 만료 | Invitation Code Expired | ✅ |
+| `:92 (INVITATION_STATUS_VAR.invite_redeemed)` | 초대 코드 사용됨 | Invitation code sent | ✅ (reworked) |
+| `:96 (WORKSPACE_STATUS_VAR.provisioning)` | 생성 중 | Creating | ✅ |
+| `:97 (WORKSPACE_STATUS_VAR.running)` | 실행 중 | Running | |
+| `:98 (WORKSPACE_STATUS_VAR.stopping)` | 정지 중 | Stopping | |
+| `:99 (WORKSPACE_STATUS_VAR.stopped)` | 정지 | Stopped | |
+| `:100 (WORKSPACE_STATUS_VAR.starting)` | 재실행 중 | Restarting | |
+| `:101 (WORKSPACE_STATUS_VAR.deleting)` | 삭제 중 | Deleting | |
+| `:102 (WORKSPACE_STATUS_VAR.error)` | 사용 불가 | Unavailable | |
+
+## 3. Auth, navigation & shared elements
+
+| Location | Korean | Proposed English |
+|---|---|---|
+| `components/auth/OwnerLockedNotice.tsx:38` | 사용할 수 없는 계정 | Account Not Available |
+| `components/auth/OwnerLockedNotice.tsx:41-47` | 이 콘솔은 {owner}이(가) 관리하고 있어요. | {owner} manages this console. (restructure) |
+| `components/auth/OwnerLockedNotice.tsx:45` | 다른 계정 | another account |
+| `components/auth/OwnerLockedNotice.tsx:49` | 이 계정으로는 콘솔을 사용할 수 없습니다. | You can't use the console with this account. |
+| `pages/LoginPage.tsx:59-61` | 로그인 중 문제가 발생했습니다. / 다시 시도해 주세요. | There was a problem signing in. / Please try again. |
+| `pages/LoginPage.tsx:75` | 콘솔을 사용하기 위해 RUNE 계정이 필요합니다. | A RUNE account is required to use the console. |
+| `components/navigation/Navbar.tsx:67` | 워크스페이스 | Workspace |
+| `components/navigation/Navbar.tsx:74` | 재생성 필요 | Recreation Required |
+| `components/navigation/Navbar.tsx:81` | 재연결 필요 | Reconnection Required |
+| `components/navigation/Navbar.tsx:92` | 워크스페이스 없음 | No Workspace |
+| `components/navigation/ProfileMenu.tsx:62` | 프로필 메뉴 | Profile menu |
+| `components/navigation/ProfileMenu.tsx:71` | 프로필 이미지 | Profile image |
+| `components/navigation/ProfileMenu.tsx:86` | 플랜: Free | Plan: Free |
+| `components/drawer/MembershipRow.tsx:49` | {name} 선택 | Select {name} (restructure) |
+| `components/drawer/MembershipRow.tsx:59` | 변경사항 있음 | Unsaved changes |
+| `components/elements/Dropdown.tsx:94` | 선택 | Select |
+| `components/elements/Pagination.tsx:32` | 페이지네이션 | Pagination |
+| `components/elements/Pagination.tsx:36` | 이전 페이지 | Previous page |
+| `components/elements/Pagination.tsx:57` | 다음 페이지 | Next page |
+| `components/elements/SearchInput.tsx:32` | 검색 | Search |
+| `components/elements/SearchInput.tsx:63` | 지우기 | Clear |
+| `components/toast/ToastContainer.tsx:36` | 클릭하여 닫기 | Click to dismiss |
+
+## 4. Teams
+
+| Location | Korean | Proposed English |
+|---|---|---|
+| `components/teams/AddMemberModal.tsx:59` | 이메일 (account) | Email (account) |
+| `components/teams/AddMemberModal.tsx:65` | 올바른 이메일 형식이 아닙니다. | Invalid email format. |
+| `components/teams/AddMemberModal.tsx:69` | 사용자 이름 (username) | Username |
+| `components/teams/AddMemberModal.tsx:70` | 사용자 이름 | Username |
+| `components/teams/AddMemberModal.tsx:77` | 권한 (role) | Role |
+| `components/teams/AddMemberModal.tsx:78` | 권한 선택 | Select Role |
+| `components/teams/AddMemberModal.tsx:84,89` | 초대받은 사용자가 rune을 연결하면 {status}으로 전환됩니다. | Once the invited user connects rune, their status changes to {status}. (restructure) |
+| `components/teams/CreateTeamModal.tsx:54`, `RenameTeamModal.tsx:61`, `TreeDetailView.tsx:264` | 같은 상위 팀에 동일한 이름이 이미 있습니다. | A team with this name already exists under the same parent team. |
+| `components/teams/CreateTeamModal.tsx:62`, `RenameTeamModal.tsx:69` | 팀 이름 | Team Name |
+| `components/teams/CreateTeamModal.tsx:63` | 예: platform-team | e.g. platform-team |
+| `components/teams/CreateTeamModal.tsx:71` | 상위 팀 (선택) | Parent Team (Optional) |
+| `components/teams/CreateTeamModal.tsx:72`, `DeleteTeamModal.tsx:139` | 팀 선택 | Select Team |
+| `components/teams/CreateTeamModal.tsx:78` | 상위 팀을 선택하면 상위 팀의 멤버가 새 팀에 자동 복사됩니다. | Selecting a parent team automatically copies its members to the new team. |
+| `components/teams/CreateTeamModal.tsx:79` | 멤버 편집은 팀 생성 후 상세 페이지에서 할 수 있습니다. | You can edit members on the detail page after the team is created. |
+| `components/teams/DeleteTeamModal.tsx:72` | 하위 팀이 있는 팀은 삭제할 수 없습니다. | Teams with sub-teams cannot be deleted. |
+| `components/teams/DeleteTeamModal.tsx:74` | 하위 팀을 먼저 삭제한 후 다시 시도해 주세요. | Delete the sub-teams first, then try again. |
+| `components/teams/DeleteTeamModal.tsx:117` | 삭제하려는 팀의 기억 처리 방식을 선택해 주세요. | Choose how to handle this team's memory before deleting. |
+| `components/teams/DeleteTeamModal.tsx:132` | ① 다른 팀으로 이전 | ① Transfer to Another Team |
+| `components/teams/DeleteTeamModal.tsx:133` | (기본값) | (Default) |
+| `components/teams/DeleteTeamModal.tsx:138` | 이전받을 팀 | Destination Team |
+| `components/teams/DeleteTeamModal.tsx:147` | 확인 - 타겟 팀명 입력 | Confirm — Enter Target Team Name |
+| `components/teams/DeleteTeamModal.tsx:148` | 팀을 먼저 선택하세요 | Select a team first |
+| `components/teams/DeleteTeamModal.tsx:154` | 타겟 팀명이 일치하지 않습니다. | Target team name does not match. |
+| `components/teams/DeleteTeamModal.tsx:172` | ② 팀 내 기억 삭제 | ② Delete This Team's Memory |
+| `components/teams/DeleteTeamModal.tsx:173` | 다른 팀과 공유 중인 기억은 해당 팀에서 계속 조회할 수 있습니다. | Memories shared with other teams will remain accessible from those teams. |
+| `components/teams/DeleteTeamModal.tsx:179` | 확인 - 삭제할 팀명 입력 | Confirm — Enter Team Name to Delete |
+| `components/teams/DeleteTeamModal.tsx:185` | 팀명이 일치하지 않습니다. | Team name does not match. |
+| `components/teams/OrgChart.tsx:142` | {name} 상세 보기 | View {name} details (restructure) |
+| `components/teams/OrgChart.tsx:238` | 축소 | Zoom Out |
+| `components/teams/OrgChart.tsx:249` | 확대 | Zoom In |
+| `components/teams/RemoveMembershipModal.tsx:40` | 다음 멤버십을 제거합니다: | The following memberships will be removed: |
+| `components/teams/RemoveMembershipModal.tsx:44`, `RoleChangeConfirmModal.tsx:42`, `TreeDetailView.tsx:583` | 멤버 이름 | Member Name |
+| `components/teams/RemoveMembershipModal.tsx:45` | 팀 | Team |
+| `components/teams/RemoveMembershipModal.tsx:46` | 권한 | Role |
+| `components/teams/RemoveMembershipModal.tsx:60` | 하위 팀 소속은 유지됩니다. 필요할 경우 개별 선택 후 제거하세요. | Sub-team memberships are kept. Select them individually to remove if needed. |
+| `components/teams/RoleChangeConfirmModal.tsx:38` | 다음 멤버의 권한을 변경합니다: | The following members' roles will be changed: |
+| `components/teams/RoleChangeConfirmModal.tsx:43` | 권한 변경 | Role Change |
+| `components/teams/TreeDetailView.tsx:190,192,193,195` | 없음 | None |
+| `components/teams/TreeDetailView.tsx:195` | {count}개 | {count} (restructure) |
+| `components/teams/TreeDetailView.tsx:241` | 변경사항이 저장되었습니다. | Changes saved. |
+| `components/teams/TreeDetailView.tsx:265` | 팀 이름 형식이 올바르지 않습니다. | Invalid team name format. |
+| `components/teams/TreeDetailView.tsx:266` | 하위 팀이 있어 삭제할 수 없습니다. | Cannot delete a team that has sub-teams. |
+| `components/teams/TreeDetailView.tsx:276` | 사용자를 찾을 수 없습니다 | User not found |
+| `components/teams/TreeDetailView.tsx:277` | 팀 멤버가 아닙니다 | Not a team member |
+| `components/teams/TreeDetailView.tsx:281` | 처리에 실패했습니다. 다시 시도해 주세요. | Processing failed. Please try again. |
+| `components/teams/TreeDetailView.tsx:287` | 이미 초대된 사용자입니다. | This user has already been invited. |
+| `components/teams/TreeDetailView.tsx:288` | 등록되지 않은 계정입니다. | This account is not registered. |
+| `components/teams/TreeDetailView.tsx:289` | 콘솔 관리자 계정은 추가할 수 없습니다. | Console admin accounts cannot be added. |
+| `components/teams/TreeDetailView.tsx:290` | 초대 코드 전송에 실패했습니다. 다시 시도해 주세요. | Failed to send the invite code. Please try again. |
+| `components/teams/TreeDetailView.tsx:309` | 팀 생성 / 팀이 생성되었습니다. | Create Team / Team created. |
+| `components/teams/TreeDetailView.tsx:313` | 팀 생성에 실패했습니다. | Failed to create the team. |
+| `components/teams/TreeDetailView.tsx:325` | 팀 이름 변경 / 팀 이름이 변경되었습니다. | Rename Team / Team name changed. |
+| `components/teams/TreeDetailView.tsx:329` | 이름 변경에 실패했습니다. | Failed to rename the team. |
+| `components/teams/TreeDetailView.tsx:344` | 팀 삭제 / 팀이 삭제되었습니다. | Delete Team / Team deleted. |
+| `components/teams/TreeDetailView.tsx:353` | 팀 삭제에 실패했습니다. | Failed to delete the team. |
+| `components/teams/TreeDetailView.tsx:365` | 멤버 추가 / 멤버를 추가했습니다. | Add Member / Member added. |
+| `components/teams/TreeDetailView.tsx:369` | 멤버 추가에 실패했습니다. | Failed to add the member. |
+| `components/teams/TreeDetailView.tsx:415` | 권한 변경에 실패했습니다. | Failed to change roles. |
+| `components/teams/TreeDetailView.tsx:439` | 멤버십이 제거되었습니다. | Memberships removed. |
+| `components/teams/TreeDetailView.tsx:447` | 멤버십 제거에 실패했습니다. | Failed to remove memberships. |
+| `components/teams/TreeDetailView.tsx:467` | 팀 트리 | Team Tree |
+| `components/teams/TreeDetailView.tsx:511-512` | 상위 팀: {parent} \| 하위 팀: {children} \| 멤버: {count}명 \| 생성일: {date} | Parent team: {parent} \| Sub-teams: {children} \| Members: {count} \| Created: {date} (restructure) |
+| `components/teams/TreeDetailView.tsx:517` | 멤버 ({total}) | Members ({total}) |
+| `components/teams/TreeDetailView.tsx:560` | 총 {total}명 · {pageSize}명/페이지 | {total} total · {pageSize} per page (restructure) |
+| `components/teams/TreeDetailView.tsx:578` | 전체 선택 | Select All |
+| `components/teams/TreeDetailView.tsx:584` | 멤버 상태 | Member Status |
+| `components/teams/TreeDetailView.tsx:585` | 역할 | Role |
+| `components/teams/TreeDetailView.tsx:586` | 합류일 | Joined |
+| `components/teams/TreeDetailView.tsx:595` | 불러오는 중… | Loading… |
+| `components/teams/TreeDetailView.tsx:600` | 멤버 목록을 불러올 수 없습니다. | Unable to load the member list. |
+| `components/teams/TreeDetailView.tsx:609` | 멤버가 없습니다. | No members. |
+| `components/teams/TreeDetailView.tsx:623` | {account} 선택 | Select {account} (restructure) |
+| `components/teams/teamOptions.ts:8` | 숫자·한글·영어와 - _ 만 사용할 수 있습니다. | Only numbers, Korean and English letters, - and _ are allowed. |
+| `components/tree/TeamTree.tsx:65` | 검색 결과가 없습니다. | No search results. |
+| `components/tree/TeamTree.tsx:66` | 팀 이름을 다시 확인해 주세요. | Check the team name and try again. |
+| `components/tree/TreeNode.tsx:84` | {name} 접기 / {name} 펼치기 | Collapse {name} / Expand {name} (restructure) |
+
+## 5. Members / users
+
+| Location | Korean | Proposed English |
+|---|---|---|
+| `components/users/CancelInvitationModal.tsx:41` | {account}의 미사용 초대 코드가 모두 만료됩니다. | All unused invitation codes for {account} will expire. (restructure) |
+| `components/users/CancelInvitationModal.tsx:42` | 유저는 삭제되지 않습니다. | The user will not be deleted. |
+| `components/users/InviteMemberModal.tsx:33 (EMAIL_FORMAT_ERROR)` | 올바른 이메일 형식이 아닙니다 | Please enter a valid email address |
+| `components/users/InviteMemberModal.tsx:35 (DUPLICATE_ACCOUNT_ERROR)` | 이미 등록된 계정입니다. 멤버 추가 또는 초대 코드 재전송을 사용하세요. | This account is already registered. Use Add Member or Resend Invitation Code instead. |
+| `components/users/InviteMemberModal.tsx:36 (SEND_FAILED_MESSAGE)` | 초대 전송에 실패했습니다. 다시 시도해 주세요. | Failed to send invitation. Please try again. |
+| `components/users/InviteMemberModal.tsx:150` | 이메일 (account) | Email (account) |
+| `components/users/InviteMemberModal.tsx:164,165` | 사용자 이름 (username) / 사용자 이름 | Username |
+| `components/users/InviteMemberModal.tsx:173` | 팀 / 권한 | Team / Role |
+| `components/users/InviteMemberModal.tsx:179`, `MemberDetailDrawer.tsx:399` | 팀 선택 | Select team |
+| `components/users/InviteMemberModal.tsx:182` | 세트 {n} 팀 | Set {n} team (aria label) |
+| `components/users/InviteMemberModal.tsx:188`, `MemberDetailDrawer.tsx:410` | 권한 선택 | Select role |
+| `components/users/InviteMemberModal.tsx:191` | 세트 {n} role | Set {n} role (aria label) |
+| `components/users/InviteMemberModal.tsx:222` | 하위 팀 권한 미리보기 | Sub-Team Role Preview |
+| `components/users/InviteMemberModal.tsx:224` (+ others) | 팀 / 권한 / 사유 | Team / Role / Reason |
+| `components/users/InviteMemberModal.tsx:238-244` | 초대 시 사용자에게 초대 코드가 발송되어 사용자가 24시간 내 rune을 연결하면 {status} 으로 전환됩니다. | When invited, the user receives an invitation code; once they connect rune within 24 hours, their status changes to {status}. (restructure) |
+| `components/users/InviteMemberModal.tsx:244-245` | 미연결 시 코드가 만료되고 초대 코드 재전송으로 사용자를 다시 초대할 수 있습니다. | If they do not connect, the code expires and you can invite them again with Resend Invitation Code. |
+| `components/users/MemberDeleteModal.tsx:9 (DELETE_FAILED_MESSAGE)` | 멤버 삭제에 실패했습니다. 다시 시도해 주세요. | Failed to delete member. Please try again. |
+| `components/users/MemberDeleteModal.tsx:24` | 소속된 팀이 없습니다. | Not a member of any team. |
+| `components/users/MemberDeleteModal.tsx:91` | {account} 계정을 삭제하며, 아래 팀에서 제거됩니다: | The account {account} will be deleted and removed from the teams below: (restructure) |
+| `components/users/MemberDeleteModal.tsx:98` | 다음 멤버의 계정을 삭제하며, 아래 팀에서 제거됩니다: | The following members' accounts will be deleted and removed from the teams below: |
+| `components/users/MemberDetailDrawer.tsx:50` | 최근 접속 {date} | Last accessed {date} |
+| `components/users/MemberDetailDrawer.tsx:54` | 초대 코드 사용됨 · 연결 대기 중 | Invitation code used · Awaiting connection |
+| `components/users/MemberDetailDrawer.tsx:57` | 최근 초대 코드 발송 {datetime} | Last invitation code sent {datetime} |
+| `components/users/MemberDetailDrawer.tsx:81 (TEAM_NOT_FOUND)` | 팀을 찾을 수 없습니다 | Team not found |
+| `components/users/MemberDetailDrawer.tsx:82 (NOT_TEAM_MEMBER)` | 팀 멤버가 아닙니다 | Not a team member |
+| `components/users/MemberDetailDrawer.tsx:86 (BATCH_REASON_FALLBACK)` | 처리에 실패했습니다. 다시 시도해 주세요. | Processing failed. Please try again. |
+| `components/users/MemberDetailDrawer.tsx:181,184` | 초대 코드 재전송 / 초대 코드를 재전송했습니다. | Resend Invitation Code / Invitation code resent. |
+| `components/users/MemberDetailDrawer.tsx:185` | 초대 코드 재전송에 실패했습니다. 다시 시도해 주세요. | Failed to resend invitation code. Please try again. |
+| `components/users/MemberDetailDrawer.tsx:225,230` | 팀 추가 / 팀에 추가되었습니다. | Add Team / Added to team. |
+| `components/users/MemberDetailDrawer.tsx:232` | 이미 소속된 팀입니다. | Already a member of this team. |
+| `components/users/MemberDetailDrawer.tsx:233` | 팀 추가에 실패했습니다. 다시 시도해 주세요. | Failed to add team. Please try again. |
+| `components/users/MemberDetailDrawer.tsx:298` | 소속 팀 ({count}) | Teams ({count}) |
+| `components/users/MemberDetailDrawer.tsx:316` | 전체선택 | Select all |
+| `components/users/MemberDetailDrawer.tsx:399` | 추가할 팀 없음 | No teams to add |
+| `components/users/MemberDetailDrawer.tsx:404` | 추가할 팀 | Team to add |
+| `components/users/MemberDetailDrawer.tsx:414` | 추가할 role | Role to add |
+| `components/users/MemberDetailDrawer.tsx:436` | 멤버 관리 | Member Management |
+| `components/users/MemberDetailDrawer.tsx:523` | 멤버십이 제거되었습니다. | Membership removed. |
+| `components/users/MemberDetailDrawer.tsx:564,570` | 세션 비활성화 / 세션을 비활성화했습니다. | Deactivate Session / Session deactivated. |
+| `components/users/MemberDetailDrawer.tsx:572` | 이미 만료된 세션입니다. | This session has already expired. |
+| `components/users/MemberDetailDrawer.tsx:573` | 세션 비활성화에 실패했습니다. 다시 시도해 주세요. | Failed to deactivate session. Please try again. |
+| `components/users/MemberDetailDrawer.tsx:589,595` | 초대 취소 / 초대를 취소했습니다. | Cancel Invitation / Invitation canceled. |
+| `components/users/MemberDetailDrawer.tsx:597` | 취소할 초대가 없습니다. | No invitation to cancel. |
+| `components/users/MemberDetailDrawer.tsx:598` | 초대 취소에 실패했습니다. 다시 시도해 주세요. | Failed to cancel invitation. Please try again. |
+| `components/users/MembershipRemoveModal.tsx:10 (REMOVE_FAILED_MESSAGE)` | 멤버십 제거에 실패했습니다. 다시 시도해 주세요. | Failed to remove membership. Please try again. |
+| `components/users/MembershipRemoveModal.tsx:70` | 다음 멤버십을 제거합니다: | The following memberships will be removed: |
+| `components/users/MembershipRemoveModal.tsx:81` | 하위 팀 소속은 유지됩니다. 필요할 경우 개별 선택 후 제거하세요. | Sub-team memberships will be kept. Select them individually to remove if needed. |
+| `components/users/RoleChangeConfirmModal.tsx:10 (UPDATE_FAILED_MESSAGE)` | 권한 변경에 실패했습니다. 다시 시도해 주세요. | Failed to change role. Please try again. |
+| `components/users/RoleChangeConfirmModal.tsx:11 (UPDATE_SUCCESS_MESSAGE)` | 권한이 변경되었습니다. | Role changed. |
+| `components/users/RoleChangeConfirmModal.tsx:64` | 다음 멤버의 권한을 변경합니다: | The following members' roles will be changed: |
+| `components/users/SessionDeactivateModal.tsx:40` | {account}의 세션을 비활성화하시겠습니까? | Deactivate the session for {account}? (restructure) |
+| `components/users/SessionDeactivateModal.tsx:41` | 모든 MCP 세션이 종료됩니다. | All MCP sessions will be terminated. |
+| `components/users/invitePreview.ts:42` | 직접 지정 | Directly assigned |
+| `components/users/invitePreview.ts:54` | 이미 초대된 팀입니다. | Team already invited. |
+| `components/users/invitePreview.ts:55` | {teamName} 하위 팀 | Sub-team of {teamName} (restructure) |
+
+## 6. Workspace
+
+| Location | Korean | Proposed English |
+|---|---|---|
+| `components/workspace/WorkspaceModal.tsx:28 (FAIL_COPY.stop)` | 워크스페이스 중지에 실패했습니다. 다시 시도해 주세요. | Failed to stop workspace. Please try again. |
+| `components/workspace/WorkspaceModal.tsx:29 (FAIL_COPY.restart)` | 워크스페이스 재실행에 실패했습니다. 다시 시도해 주세요. | Failed to restart workspace. Please try again. |
+| `components/workspace/WorkspaceModal.tsx:94` | 워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요. | Failed to recreate workspace. Please try again. |
+| `components/workspace/WorkspaceModal.tsx:107` | 워크스페이스 삭제에 실패했습니다. 다시 시도해 주세요. | Failed to delete workspace. Please try again. |
+| `components/workspace/WorkspaceModal.tsx:116` | 워크스페이스를 삭제하시겠습니까? | Delete this workspace? |
+| `components/workspace/WorkspaceModal.tsx:118` | 삭제 후에는 되돌릴 수 없습니다. | This action cannot be undone. |
+| `components/workspace/WorkspaceModal.tsx:159` | 기존 워크스페이스를 삭제하는 중입니다… | Deleting the existing workspace… |
+| `components/workspace/WorkspaceModal.tsx:161` | 삭제가 완료되면 워크스페이스 생성을 시작합니다. | Workspace creation will start once deletion is complete. |
+| `components/workspace/WorkspaceModal.tsx:165` | 콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다. | The console was reinstalled and can no longer connect to this workspace. |
+| `components/workspace/WorkspaceModal.tsx:167` | 기존에 저장된 데이터는 이전 보안 키로 암호화되어 복구할 수 없습니다. | Previously stored data is encrypted with the old security key and cannot be recovered. |
+| `components/workspace/WorkspaceModal.tsx:169` | 삭제 후 재생성하면 빈 워크스페이스로 다시 시작합니다. | Delete and recreate to start over with an empty workspace. |
+| `components/workspace/WorkspaceModal.tsx:208` | 워크스페이스 연결이 만료되었습니다. | The workspace connection has expired. |
+| `components/workspace/WorkspaceModal.tsx:210` | 재연결하여 데이터 플레인을 다시 활성화해 주세요. | Reconnect to reactivate the data plane. |
+| `components/workspace/WorkspaceModal.tsx:213` | 재연결에 실패했습니다. 다시 시도해 주세요. | Failed to reconnect. Please try again. |
+| `components/workspace/WorkspaceModal.tsx:242` | 워크스페이스 정보를 불러올 수 없습니다. | Unable to load workspace information. |
+| `components/workspace/WorkspaceModal.tsx:244` | 잠시 후 다시 시도해 주세요. | Please try again later. |
+| `components/workspace/WorkspaceModal.tsx:289` | 플랜 | Plan |
+| `components/workspace/WorkspaceModal.tsx:293` | 상태 | Status |
+| `components/workspace/WorkspaceModal.tsx:297` | 저장된 기억 개수 | Stored Memories |
+| `pages/WorkspacePage.tsx:65,87` | 워크스페이스 관리 | Workspace management |
+| `pages/WorkspacePage.tsx:91` | 워크스페이스를 생성하는 중입니다… | Creating your workspace… |
+| `pages/WorkspacePage.tsx:92` | 생성까지 약 3~5분 정도 소요됩니다. | This takes about 3–5 minutes. |
+| `pages/WorkspacePage.tsx:98` | 워크스페이스 생성 실패 | Workspace Creation Failed |
+| `pages/WorkspacePage.tsx:99` | 워크스페이스를 생성할 수 없습니다. 다시 시도해 주세요. | Couldn't create the workspace. Please try again. |
+| `pages/WorkspacePage.tsx:101,109` | 워크스페이스 생성 | Create Workspace |
+| `pages/WorkspacePage.tsx:106` | 생성된 워크스페이스가 없습니다. | No workspace has been created. |
+| `pages/WorkspacePage.tsx:107` | 워크스페이스를 생성하면 기억(memory)을 저장할 수 있습니다. | Create a workspace to store memory. |
+
+## 7. Pages (sessions, teams, users) & updates
+
+| Location | Korean | Proposed English |
+|---|---|---|
+| `pages/SessionsPage.tsx:30`, `UsersPage.tsx:79,444` | 멤버 이름 | Member Name |
+| `pages/SessionsPage.tsx:31` | 최근 발급 시간 | Last Issued |
+| `pages/SessionsPage.tsx:32,140` | 최근 접속 시간 | Last Accessed |
+| `pages/SessionsPage.tsx:77,102` | 세션 기록 | Session history |
+| `pages/SessionsPage.tsx:84` | 이력 정보를 불러올 수 없습니다. | Couldn't load history. |
+| `pages/SessionsPage.tsx:85`, `TeamsPage.tsx:134`, `UsersPage.tsx:290` | 새로고침 후 다시 시도해 주세요. | Refresh and try again. |
+| `pages/SessionsPage.tsx:111`, `UsersPage.tsx:357` | 정렬 기준 | Sort by |
+| `pages/SessionsPage.tsx:117`, `UsersPage.tsx:363` | 정렬 | Sort |
+| `pages/SessionsPage.tsx:124` | 총 {total}건 · {PAGE_SIZE}건/페이지 | {total} total · {PAGE_SIZE} per page (restructure) |
+| `pages/SessionsPage.tsx:138` | 사용자 | Users |
+| `pages/SessionsPage.tsx:139` | 발급 시간 | Issued At |
+| `pages/SessionsPage.tsx:149`, `UsersPage.tsx:455` | 불러오는 중… | Loading… |
+| `pages/SessionsPage.tsx:159` | 이력이 없습니다. | No history. |
+| `pages/TeamsPage.tsx:19` | 같은 상위 팀에 동일한 이름이 이미 있습니다. | A team with this name already exists under the same parent team. |
+| `pages/TeamsPage.tsx:20` | 팀 이름 형식이 올바르지 않습니다. | Invalid team name format. |
+| `pages/TeamsPage.tsx:73` | 팀 생성 / 팀이 생성되었습니다. | Create Team / Team created. |
+| `pages/TeamsPage.tsx:77` | 팀 생성에 실패했습니다. | Failed to create team. |
+| `pages/TeamsPage.tsx:123,130,151` | 팀 관리 | Team management |
+| `pages/TeamsPage.tsx:133` | 팀 정보를 불러올 수 없습니다. | Couldn't load teams. |
+| `pages/TeamsPage.tsx:153` | 보기 전환 | Switch view |
+| `pages/TeamsPage.tsx:162` | 트리·상세 | Tree & Detail |
+| `pages/TeamsPage.tsx:172` | 조직도 | Org Chart |
+| `pages/TeamsPage.tsx:180` | 팀 검색 | Search teams |
+| `pages/TeamsPage.tsx:190` | 새로운 팀을 만들어 주세요. | Create your first team. |
+| `pages/TeamsPage.tsx:191` | 팀을 생성하면 멤버와 기억(memory)을 관리할 수 있습니다. | Create a team to manage members and memory. |
+| `pages/UsersPage.tsx:62,73` | 전체 | All |
+| `pages/UsersPage.tsx:63,64` | 온라인 / 오프라인 | Online / Offline |
+| `pages/UsersPage.tsx:78` | 최근 초대 코드 발송 | Last Invitation Code Sent |
+| `pages/UsersPage.tsx:96` | 사용자를 찾을 수 없습니다 | User not found |
+| `pages/UsersPage.tsx:238` | 초대 코드 재전송 / 초대 코드를 재전송했습니다. | Resend Invitation Code / Invitation code resent. |
+| `pages/UsersPage.tsx:242` | 재전송 실패 | Resend failed |
+| `pages/UsersPage.tsx:269` | 멤버 삭제 / 멤버를 삭제했습니다. | Delete Members / Members deleted. |
+| `pages/UsersPage.tsx:286,309,336` | 멤버 관리 | Member management |
+| `pages/UsersPage.tsx:289` | 멤버 정보를 불러올 수 없습니다. | Couldn't load members. |
+| `pages/UsersPage.tsx:312` | 아직 초대한 멤버가 없습니다 | No members invited yet |
+| `pages/UsersPage.tsx:313` | 멤버를 초대하면 초대 코드가 이메일로 발송됩니다 | Invite a member and an invitation code will be sent by email |
+| `pages/UsersPage.tsx:350` | 이름 검색 | Search by name |
+| `pages/UsersPage.tsx:368,445` | 멤버 상태 | Member Status |
+| `pages/UsersPage.tsx:374` | status 필터 | Status filter |
+| `pages/UsersPage.tsx:379` | 팀 | Team |
+| `pages/UsersPage.tsx:384` | group 필터 | Team filter |
+| `pages/UsersPage.tsx:423` | 총 {total}명 · {PAGE_SIZE}명/페이지 | {total} members · {PAGE_SIZE} per page (restructure) |
+| `pages/UsersPage.tsx:439` | 전체 선택 | Select all |
+| `pages/UsersPage.tsx:446` | 팀 (권한) | Team (Role) |
+| `pages/UsersPage.tsx:465` | 검색 결과가 없습니다. | No results found. |
+| `pages/UsersPage.tsx:483` | {account} 선택 | Select {account} (restructure) |
+| `components/update/UpdateFloatingCard.tsx:133` | 콘솔을 업데이트하는 중입니다 | Updating the Console |
+| `components/update/UpdateFloatingCard.tsx:135` | 업데이트에 실패했습니다 | Update Failed |
+| `components/update/UpdateFloatingCard.tsx:136` | 새 버전이 출시되었습니다 | New Version Available |
+| `components/update/UpdateFloatingCard.tsx:161` | 백업 및 업데이트를 진행하고 있습니다… | Backing up and updating… |
+| `components/update/UpdateFloatingCard.tsx:162` | 업데이트를 준비하고 있습니다… | Preparing the update… |
+| `components/update/UpdateFloatingCard.tsx:167` | 업데이트를 완료하지 못했습니다. 상태를 확인한 뒤 다시 시도해 주세요. | The update could not be completed. Check the status and try again. |
+| `components/update/UpdateFloatingCard.tsx:171` | 콘솔이 재시작되는 동안 RUNE 사용이 일시적으로 중단될 수 있습니다. | RUNE may be temporarily unavailable while the console restarts. |
+| `utils/username.ts:7` | 한글, 영문 소문자, 단어 사이 공백 1칸만 입력할 수 있습니다. | Only Korean, lowercase English letters, and single spaces between words are allowed. |
+
+## 8. Dev-only fixtures (translate last / optional)
+
+`pages/UITestPage.tsx` (~90 strings: component showcase page, not linked in nav)
+and `pages/teamsDummyData.ts` (~50 dummy team names: 플랫폼→Platform, 백엔드→Backend,
+프론트엔드→Frontend, 디자인→Design, 보안→Security, 인프라→Infrastructure, etc.).
+These never render for real users; recommend translating only if we want the
+showcase page in English too. Full extraction available on request.
+
+## 9. Korean in code comments only (no UI impact — translate optionally)
+
+- `components/auth/LandingRedirect.tsx:10,13` · `components/elements/Dropdown.tsx:22` · `components/elements/NoticeModal.tsx:11` · `components/elements/SearchInput.tsx:26,27` · `components/elements/TextButton.tsx:20,21` · `components/elements/WorkspaceStatus.tsx:13` · `components/layout/DrawerLayout.tsx:34` · `components/layout/ModalLayout.tsx:17` · `components/navigation/Navbar.tsx:25,58,72,79` · `components/navigation/PublicNavbar.tsx:14` · `components/table/TableFoot.tsx:18`
+- `components/teams/*`: AddMemberModal:30 · CreateTeamModal:29 · DeleteTeamModal:46 · OrgChart:57,73,103,177,183 · RemoveMembershipModal:26 · RenameTeamModal:31 · RoleChangeConfirmModal:26,28 · TreeDetailView:103,106,150 · tree/TeamTree:23
+- `components/users/*`: CancelInvitationModal:17 · InviteMemberModal:54-57,78 · MemberDeleteModal:15,29,38,40,44 · MemberDetailDrawer:39,41,115,120,350,351,393,439,441,445 · MembershipRemoveModal:26,30 · RoleChangeConfirmModal:17,27,29,31,32 · SessionDeactivateModal:17 · invitePreview:8,22,23
+- `components/workspace/WorkspaceModal.tsx:38,49,50,51,101,152` · `api/userAPIs.ts:25`
+- `pages/*`: NotFoundPage:12 · SessionsPage:74,100 · TeamsPage:29,44,46,58,83-84,113 · UsersPage:59,283,305 · WorkspacePage:29,31,51
+- `hooks/mutations/useUserMembershipMutations.ts:28` · `hooks/mutations/useWorkspaceMutations.ts:17,31,34,37,40,72` · `hooks/queries/useWorkspaceQuery.ts:24` · `stores/noticeStore.ts:13` · `stores/workspaceStore.ts:4,9,10,12,13,17,20` · `types/userTypes.ts:40` · `utils/username.ts:1-2,9`
+
+---
+
+### Notes / decisions to make
+
+1. **Validation rules mentioning Korean**: `teamOptions.ts:8` and `utils/username.ts:7`
+ describe regexes that *allow Korean characters*. If the product goes English-first,
+ decide whether the validation itself should still accept Hangul (translation above
+ keeps the current behavior, just described in English).
+2. **"기억 (memory)"**: translated as "memory" throughout (matches RUNE's product term).
+3. **"권한" vs "역할"**: both are used for role in Korean; unified as **Role** in English.
+4. **초대 코드**: unified as **invitation code** (not "invite code" — one occurrence
+ in `TreeDetailView.tsx:290` says "invite code"; change to match if desired).
diff --git a/frontend/package.json b/frontend/package.json
index 5ac9372..226032b 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",
@@ -19,6 +19,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"clsx": "^2.1.1",
+ "i18next": "^26.3.6",
"lucide-react": "^1.24.0",
"pretendard": "^1.3.9",
"react": "^19.2.7",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index f1fc272..76b8f4b 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ i18next:
+ specifier: ^26.3.6
+ version: 26.3.6(typescript@6.0.3)
lucide-react:
specifier: ^1.24.0
version: 1.24.0(react@19.2.7)
@@ -1029,6 +1032,14 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ i18next@26.3.6:
+ resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
+ peerDependencies:
+ typescript: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -2573,6 +2584,10 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ i18next@26.3.6(typescript@6.0.3):
+ optionalDependencies:
+ typescript: 6.0.3
+
ignore@5.3.2: {}
ignore@7.0.5: {}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2a90430..e94cb4c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,19 +1,25 @@
+import { lazy, Suspense } from "react";
import { Route, Routes } from "react-router";
import LoginPage from "@/pages/LoginPage";
import NotFoundPage from "@/pages/NotFoundPage";
import SessionsPage from "@/pages/SessionsPage";
import TeamsPage from "@/pages/TeamsPage";
-import UITestPage from "@/pages/UITestPage";
import UsersPage from "@/pages/UsersPage";
import WorkspacePage from "@/pages/WorkspacePage";
import LandingRedirect from "@/components/auth/LandingRedirect";
import RequireAuth from "@/components/auth/RequireAuth";
import NoticeModal from "@/components/elements/NoticeModal";
+import ToastContainer from "@/components/elements/ToastContainer";
import AppLayout from "@/components/layout/AppLayout";
-import ToastContainer from "@/components/toast/ToastContainer";
import { PATH_LIST } from "@/constants/commonConstants";
+/* Dev-only UI showcase — the import.meta.env.DEV guard is statically false in
+ production builds, so Rollup drops both the route and the chunk entirely. */
+const UITestPage = import.meta.env.DEV
+ ? lazy(() => import("@/pages/UITestPage"))
+ : null;
+
/** App defines the top-level route table for Rune Console. */
const App = () => {
return (
@@ -27,7 +33,16 @@ const App = () => {
} />
} />
} />
- } />
+ {import.meta.env.DEV && UITestPage && (
+
+
+
+ }
+ />
+ )}
{/* 404 (SC-04) — reachable regardless of auth; outside RequireAuth so a
diff --git a/frontend/src/components/LocaleProvider.tsx b/frontend/src/components/LocaleProvider.tsx
new file mode 100644
index 0000000..627458a
--- /dev/null
+++ b/frontend/src/components/LocaleProvider.tsx
@@ -0,0 +1,27 @@
+import { useEffect, useState, type ReactNode } from "react";
+
+import { i18n } from "@/locales";
+
+/**
+ * LocaleProvider makes the module-global `L` Proxy reactive without a page
+ * reload. `L.x.y` reads the current language on every access, but a module
+ * global can't tell React to re-render — so this boundary subscribes to
+ * i18next's `languageChanged` event and remounts its subtree (via a changing
+ * `key`) when the language switches. The tree then re-renders in place — no
+ * reload, so no flash — and every `L.x.y` re-resolves to the new language.
+ */
+export default function LocaleProvider({ children }: { children: ReactNode }) {
+ const [lang, setLang] = useState(i18n.language);
+
+ useEffect(() => {
+ const onChange = (next: string) => setLang(next);
+ i18n.on("languageChanged", onChange);
+ if (i18n.language !== lang) setLang(i18n.language);
+ return () => {
+ i18n.off("languageChanged", onChange);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return
{children}
;
+}
diff --git a/frontend/src/components/auth/OwnerLockedNotice.tsx b/frontend/src/components/auth/OwnerLockedNotice.tsx
new file mode 100644
index 0000000..8259ce2
--- /dev/null
+++ b/frontend/src/components/auth/OwnerLockedNotice.tsx
@@ -0,0 +1,64 @@
+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";
+import { L } from "@/locales";
+
+/**
+ * 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 (
+
+
+
+
+
+
+
{L.auth.ownerLockedTitle}
+
+
+ {L.auth.ownerLockedPrefix}
+ {owner ? (
+ {owner}
+ ) : (
+ L.auth.ownerFallback
+ )}
+ {L.auth.ownerLockedSuffix}
+
+ {L.auth.ownerLockedBody}
+
+
+
+
+
+ );
+};
+
+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/components/elements/Dropdown.tsx b/frontend/src/components/elements/Dropdown.tsx
index cf06022..5bcf54d 100644
--- a/frontend/src/components/elements/Dropdown.tsx
+++ b/frontend/src/components/elements/Dropdown.tsx
@@ -5,6 +5,7 @@ import { ChevronDown } from "lucide-react";
import { cn } from "@/utils/cn";
import type { TDropdownOption } from "@/types/commonTypes";
+import { L } from "@/locales";
const styles = {
field: "grid w-full min-w-0 gap-2",
@@ -91,7 +92,7 @@ interface DropdownProps {
const Dropdown = ({
options,
label,
- placeholder = "선택",
+ placeholder = L.common.select,
hint,
error,
value,
diff --git a/frontend/src/components/elements/NoticeModal.tsx b/frontend/src/components/elements/NoticeModal.tsx
index a789aee..3dc697f 100644
--- a/frontend/src/components/elements/NoticeModal.tsx
+++ b/frontend/src/components/elements/NoticeModal.tsx
@@ -1,8 +1,8 @@
import Button from "@/components/elements/Button";
import ModalLayout from "@/components/layout/ModalLayout";
+import { useNoticeStore } from "@/state/store/noticeStore";
import { cn } from "@/utils/cn";
import { BTN_TEXT } from "@/constants/commonConstants";
-import { useNoticeStore } from "@/stores/noticeStore";
/**
* NoticeModal is the shared blocking result-notice modal. The title names
diff --git a/frontend/src/components/elements/Pagination.tsx b/frontend/src/components/elements/Pagination.tsx
index 2195a30..3d30535 100644
--- a/frontend/src/components/elements/Pagination.tsx
+++ b/frontend/src/components/elements/Pagination.tsx
@@ -1,4 +1,5 @@
import { cn } from "@/utils/cn";
+import { L } from "@/locales";
const styles = {
wrap: "flex w-fit items-center gap-2 font-mono text-sm",
@@ -10,6 +11,15 @@ const styles = {
numActive: "bg-mint text-on-mint font-semibold hover:text-on-mint",
};
+/** Sliding 5-page window centered on the current page, clamped at both
+ ends so exactly min(5, totalPages) numbers always show — the button
+ count never jumps while paging (1→[1..5], 4→[2..6], 9/10→[6..10]). */
+const pageWindow = (page: number, totalPages: number): number[] => {
+ const size = Math.min(5, totalPages);
+ const start = Math.min(Math.max(1, page - 2), totalPages - size + 1);
+ return Array.from({ length: size }, (_, i) => start + i);
+};
+
interface PaginationProps {
page: number;
totalPages: number;
@@ -19,8 +29,10 @@ interface PaginationProps {
/**
* Pagination is the numbered pager (wireframe spec form): ‹ 1 2 3 ›.
- * The current page is filled mint; boundary arrows disable. Page-count
- * ellipsis (…) is deferred until a screen needs it.
+ * The current page is filled mint; boundary arrows disable. Large page
+ * counts show a sliding 5-page window around the current page — the
+ * session-history table grows without bound, so an unwindowed row of
+ * hundreds of buttons is not an option.
*/
const Pagination = ({
page,
@@ -29,17 +41,20 @@ const Pagination = ({
className,
}: PaginationProps) => {
return (
-
+
onChange(page - 1)}
>
‹
- {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
+ {pageWindow(page, totalPages).map((n) => (
= totalPages}
onClick={() => onChange(page + 1)}
>
diff --git a/frontend/src/components/elements/SearchInput.tsx b/frontend/src/components/elements/SearchInput.tsx
index 614cd5c..6cfadc0 100644
--- a/frontend/src/components/elements/SearchInput.tsx
+++ b/frontend/src/components/elements/SearchInput.tsx
@@ -1,6 +1,7 @@
import { Search } from "lucide-react";
import { cn } from "@/utils/cn";
+import { L } from "@/locales";
const styles = {
wrap: "relative inline-flex h-8 w-full items-center rounded-[8px] border border-border-strong bg-well/70 transition-[border-color,box-shadow] duration-[160ms] focus-within:border-mint/60 focus-within:shadow-[0_0_0_3px] focus-within:shadow-mint/10",
@@ -29,7 +30,7 @@ interface SearchInputProps {
const SearchInput = ({
value,
onChange,
- placeholder = "검색",
+ placeholder = L.elements.search,
maxLength,
disabled = false,
ariaLabel,
@@ -60,7 +61,7 @@ const SearchInput = ({
onChange("")}
>
✕
diff --git a/frontend/src/components/toast/ToastContainer.tsx b/frontend/src/components/elements/ToastContainer.tsx
similarity index 92%
rename from frontend/src/components/toast/ToastContainer.tsx
rename to frontend/src/components/elements/ToastContainer.tsx
index 15c30f9..ccd6511 100644
--- a/frontend/src/components/toast/ToastContainer.tsx
+++ b/frontend/src/components/elements/ToastContainer.tsx
@@ -1,7 +1,8 @@
import { createPortal } from "react-dom";
+import { useToastStore } from "@/state/store/toastStore";
import { cn } from "@/utils/cn";
-import { useToastStore } from "@/stores/toastStore";
+import { L } from "@/locales";
const styles = {
stack: "fixed top-4 right-4 z-90 flex flex-col items-end gap-2",
@@ -33,7 +34,7 @@ const ToastContainer = () => {
key={toast.id}
role={toast.tone === "error" ? "alert" : "status"}
className={cn(styles.toast, toast.leaving && styles.toastLeaving)}
- title="클릭하여 닫기"
+ title={L.elements.clickToDismiss}
onClick={() => dismissToast(toast.id)}
>
{
+const WorkspaceStatus = ({
+ status,
+ onClick,
+ className,
+}: WorkspaceStatusProps) => {
const classes = cn(
"text-tag inline-flex h-[26px] w-fit cursor-pointer items-center rounded-full border border-current px-2 whitespace-nowrap",
WORKSPACE_STATUS_VAR[status].color,
diff --git a/frontend/src/components/elements/__tests__/NoticeModal.test.tsx b/frontend/src/components/elements/__tests__/NoticeModal.test.tsx
index 911c7e3..e20c02f 100644
--- a/frontend/src/components/elements/__tests__/NoticeModal.test.tsx
+++ b/frontend/src/components/elements/__tests__/NoticeModal.test.tsx
@@ -2,8 +2,8 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import NoticeModal from "@/components/elements/NoticeModal";
+import { useNoticeStore } from "@/state/store/noticeStore";
import { BTN_TEXT } from "@/constants/commonConstants";
-import { useNoticeStore } from "@/stores/noticeStore";
afterEach(() => {
useNoticeStore.setState({ notice: null });
diff --git a/frontend/src/components/elements/__tests__/Pagination.test.tsx b/frontend/src/components/elements/__tests__/Pagination.test.tsx
index 1cd06a9..d94e0ab 100644
--- a/frontend/src/components/elements/__tests__/Pagination.test.tsx
+++ b/frontend/src/components/elements/__tests__/Pagination.test.tsx
@@ -18,6 +18,36 @@ describe("Pagination", () => {
);
});
+ it("shows a sliding 5-page window centered on the current page", () => {
+ render( {}} />);
+ /* Visible: 23 24 [25] 26 27 — always exactly five numbers. */
+ for (const n of ["23", "24", "25", "26", "27"]) {
+ expect(screen.getByRole("button", { name: n })).toBeInTheDocument();
+ }
+ expect(screen.queryByRole("button", { name: "1" })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "50" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("clamps the window at both ends so the button count never jumps", () => {
+ /* Head: page 1 of 53 → 1 2 3 4 5. */
+ const { rerender } = render(
+ {}} />,
+ );
+ for (const n of ["1", "2", "3", "4", "5"]) {
+ expect(screen.getByRole("button", { name: n })).toBeInTheDocument();
+ }
+ expect(screen.queryByRole("button", { name: "6" })).not.toBeInTheDocument();
+
+ /* Near the tail: page 9 of 10 → 6 7 8 9 10. */
+ rerender( {}} />);
+ for (const n of ["6", "7", "8", "9", "10"]) {
+ expect(screen.getByRole("button", { name: n })).toBeInTheDocument();
+ }
+ expect(screen.queryByRole("button", { name: "5" })).not.toBeInTheDocument();
+ });
+
it("disables prev on the first page and next on the last", () => {
const { rerender } = render(
{}} />,
diff --git a/frontend/src/components/toast/__tests__/ToastContainer.test.tsx b/frontend/src/components/elements/__tests__/ToastContainer.test.tsx
similarity index 93%
rename from frontend/src/components/toast/__tests__/ToastContainer.test.tsx
rename to frontend/src/components/elements/__tests__/ToastContainer.test.tsx
index c2cf909..822e096 100644
--- a/frontend/src/components/toast/__tests__/ToastContainer.test.tsx
+++ b/frontend/src/components/elements/__tests__/ToastContainer.test.tsx
@@ -1,8 +1,8 @@
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import ToastContainer from "@/components/toast/ToastContainer";
-import { useToastStore } from "@/stores/toastStore";
+import ToastContainer from "@/components/elements/ToastContainer";
+import { useToastStore } from "@/state/store/toastStore";
describe("ToastContainer", () => {
beforeEach(() => {
diff --git a/frontend/src/components/navigation/LanguageToggle.tsx b/frontend/src/components/navigation/LanguageToggle.tsx
new file mode 100644
index 0000000..3a48cf4
--- /dev/null
+++ b/frontend/src/components/navigation/LanguageToggle.tsx
@@ -0,0 +1,29 @@
+import { Globe } from "lucide-react";
+
+import { getLanguage, setLanguage } from "@/locales";
+
+/**
+ * LanguageToggle is the always-visible navbar language switch (globe + label,
+ * the top-right convention users scan for). The label names the language it
+ * switches TO, in that language's own script, so a user stuck in the wrong
+ * language can still recognize the way out. setLanguage switches i18next in
+ * place (no reload — LocaleProvider re-renders). Reads getLanguage() at render
+ * (not the load-time const) so after a switch it offers the other direction.
+ */
+const LanguageToggle = () => {
+ const nextLanguage = getLanguage() === "ko" ? "en" : "ko";
+ const nextLanguageLabel = getLanguage() === "ko" ? "English" : "한국어";
+
+ return (
+ setLanguage(nextLanguage)}
+ >
+
+ {nextLanguageLabel}
+
+ );
+};
+
+export default LanguageToggle;
diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx
index bae937e..5d258b6 100644
--- a/frontend/src/components/navigation/Navbar.tsx
+++ b/frontend/src/components/navigation/Navbar.tsx
@@ -3,17 +3,19 @@ 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";
import { useWorkspaceQuery } from "@/hooks/queries/useWorkspaceQuery";
import { postLogout } from "@/api/authAPIs";
+import { useWorkspaceStore } from "@/state/store/workspaceStore";
import {
BRAND_WORDMARK,
PATH_LIST,
QUERY_KEYS,
} from "@/constants/commonConstants";
-import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { L } from "@/locales";
/**
* Navbar is the console top bar (SC-03). Its background and bottom border
@@ -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 && (
- navigate(PATH_LIST.login)}
- className="w-auto"
- />
- )}
+
+
+ {!onLoginPage && (
+ navigate(PATH_LIST.login)}
+ className="w-auto"
+ />
+ )}
+
);
diff --git a/frontend/src/components/navigation/__tests__/Navbar.test.tsx b/frontend/src/components/navigation/__tests__/Navbar.test.tsx
index c9a1628..3af4056 100644
--- a/frontend/src/components/navigation/__tests__/Navbar.test.tsx
+++ b/frontend/src/components/navigation/__tests__/Navbar.test.tsx
@@ -8,8 +8,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import Navbar from "@/components/navigation/Navbar";
import * as authAPIs from "@/api/authAPIs";
import * as workspaceAPIs from "@/api/workspaceAPIs";
+import { useWorkspaceStore } from "@/state/store/workspaceStore";
import { BTN_TEXT } from "@/constants/commonConstants";
-import { useWorkspaceStore } from "@/stores/workspaceStore";
const jsonRes = (body: unknown) =>
({ ok: true, status: 200, json: async () => body }) as unknown as Response;
diff --git a/frontend/src/components/table/TableEmptyRow.tsx b/frontend/src/components/table/TableEmptyRow.tsx
new file mode 100644
index 0000000..6c6aa46
--- /dev/null
+++ b/frontend/src/components/table/TableEmptyRow.tsx
@@ -0,0 +1,27 @@
+import type { ReactNode } from "react";
+
+interface TableEmptyRowProps {
+ colSpan: number;
+ /** Per-table empty copy (검색 결과가 없습니다 · 이력이 없습니다 · …). */
+ children: ReactNode;
+}
+
+/**
+ * TableEmptyRow is the shared zero-rows state for server-paged tables —
+ * a full-width muted line under the header rule, matching
+ * TableLoadingRow's height so pending → empty never shifts the layout.
+ */
+const TableEmptyRow = ({ colSpan, children }: TableEmptyRowProps) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export default TableEmptyRow;
diff --git a/frontend/src/components/table/TableLoadingRow.tsx b/frontend/src/components/table/TableLoadingRow.tsx
new file mode 100644
index 0000000..757299d
--- /dev/null
+++ b/frontend/src/components/table/TableLoadingRow.tsx
@@ -0,0 +1,25 @@
+import { L } from "@/locales";
+
+interface TableLoadingRowProps {
+ colSpan: number;
+}
+
+/**
+ * TableLoadingRow is the shared pending state for server-paged tables —
+ * one full-width faint line so every list (users, sessions, team
+ * members) waits with the same height and copy.
+ */
+const TableLoadingRow = ({ colSpan }: TableLoadingRowProps) => {
+ return (
+
+
+ {L.common.loading}
+
+
+ );
+};
+
+export default TableLoadingRow;
diff --git a/frontend/src/components/teams/AddMemberModal.tsx b/frontend/src/components/teams/AddMemberModal.tsx
index 518bfff..197dfec 100644
--- a/frontend/src/components/teams/AddMemberModal.tsx
+++ b/frontend/src/components/teams/AddMemberModal.tsx
@@ -6,16 +6,22 @@ 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 { ROLE_OPTIONS } from "@/components/teams/teamOptions";
+import { EMAIL_PATTERN } from "@/utils/email";
import {
isSubmittableUsername,
normalizeUsernameInput,
USERNAME_MAX_LENGTH,
validateUsername,
} from "@/utils/username";
-import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
-
-const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+import {
+ BTN_TEXT,
+ INPUT_LABELS,
+ MODAL_TITLES,
+ PLACEHOLDERS,
+} from "@/constants/commonConstants";
+import { MODAL_STYLE_VAR } from "@/constants/styleConstants";
+import { ROLE_OPTIONS } from "@/constants/teamConstants";
+import { L } from "@/locales";
interface AddMemberModalProps {
teamName: string;
@@ -56,41 +62,41 @@ const AddMemberModal = ({
setUsername(normalizeUsernameInput(value))}
error={usernameError}
/>
- 초대받은 사용자가 rune을 연결하면{" "}
+ {L.teams.invitePrefix}
- 으로 전환됩니다.
+ {L.teams.inviteSuffix}
{error && {error} }
-
+
0 && !isInvalidFormat && !isDuplicate;
const nameError = isInvalidFormat
- ? TEAM_NAME_RULE_TEXT
+ ? L.teams.teamNameRule
: trimmed && isDuplicate
- ? "같은 상위 팀에 동일한 이름이 이미 있습니다."
+ ? L.teams.dupName
: undefined;
return (
-
+
- 상위 팀을 선택하면 상위 팀의 멤버가 새 팀에 자동 복사됩니다.
- 멤버 편집은 팀 생성 후 상세 페이지에서 할 수 있습니다.
+ {L.teams.parentCopyInfo1}
+ {L.teams.parentCopyInfo2}
{error && {error} }
-
+
-
- 하위 팀이 있는 팀은 삭제할 수 없습니다.
+
+ {L.teams.hasChildrenAlert1}
- 하위 팀을 먼저 삭제한 후 다시 시도해 주세요.
+ {L.teams.hasChildrenAlert2}
-
-
- 삭제하려는 팀의 기억 처리 방식을 선택해 주세요.
-
+
+
{L.teams.memoryChoiceInfo}
{/* Option ① — transfer (default): the card is the selection area */}
@@ -169,20 +171,20 @@ const DeleteTeamModal = ({
name="memory-action"
checked={action === "purge"}
onChange={selectPurge}
- label="② 팀 내 기억 삭제"
- desc="다른 팀과 공유 중인 기억은 해당 팀에서 계속 조회할 수 있습니다."
+ label={L.teams.purgeOption}
+ desc={L.teams.purgeDesc}
className={styles.optionRadio}
/>
@@ -190,7 +192,7 @@ const DeleteTeamModal = ({
{error && {error} }
-
+
onSelect(node.id)}
>
-
- {node.name}
+
+ {localizeTeamName(node.name)}
@@ -235,7 +237,7 @@ const OrgChart = ({ teams, query, onSelectTeam }: OrgChartProps) => {
= ZOOM_SCALES.length - 1}
onClick={() => setZoomStep((step) => step + 1)}
>
@@ -245,7 +247,7 @@ const OrgChart = ({ teams, query, onSelectTeam }: OrgChartProps) => {
setZoomStep((step) => step - 1)}
>
diff --git a/frontend/src/components/teams/RemoveMembershipModal.tsx b/frontend/src/components/teams/RemoveMembershipModal.tsx
deleted file mode 100644
index 5fb8d92..0000000
--- a/frontend/src/components/teams/RemoveMembershipModal.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import Button from "@/components/elements/Button";
-import Notice from "@/components/elements/Notice";
-import ModalLayout from "@/components/layout/ModalLayout";
-import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
-import type { TTeamMemberRole } from "@/types/teamTypes";
-
-export interface TMembershipRemoval {
- account: string;
- role: TTeamMemberRole;
-}
-
-interface RemoveMembershipModalProps {
- teamName: string;
- members: TMembershipRemoval[];
- onClose: () => void;
- onConfirm: () => void;
-}
-
-const styles = {
- table: "w-full border-collapse text-sm",
- th: "border-border text-faint border px-3 py-1.5 text-left font-mono text-tag font-medium tracking-[0.08em]",
- td: "border-border text-muted-foreground border px-3 py-1.5",
-};
-
-/**
- * RemoveMembershipModal is the 멤버십 제거 confirmation (SC-14): lists
- * exactly the memberships being removed (account · team · role) — only
- * what is listed is removed, no sub-team cascade (C10). Mount
- * conditionally.
- */
-const RemoveMembershipModal = ({
- teamName,
- members,
- onClose,
- onConfirm,
-}: RemoveMembershipModalProps) => {
- return (
-
-
-
다음 멤버십을 제거합니다:
-
-
-
- 멤버 이름
- 팀
- 권한
-
-
-
- {members.map((member) => (
-
- {member.account}
- {teamName}
- {member.role}
-
- ))}
-
-
-
- 하위 팀 소속은 유지됩니다. 필요할 경우 개별 선택 후 제거하세요.
-
-
-
-
-
-
-
- );
-};
-
-export default RemoveMembershipModal;
diff --git a/frontend/src/components/teams/RenameTeamModal.tsx b/frontend/src/components/teams/RenameTeamModal.tsx
index 828c00e..d1c8d31 100644
--- a/frontend/src/components/teams/RenameTeamModal.tsx
+++ b/frontend/src/components/teams/RenameTeamModal.tsx
@@ -4,12 +4,11 @@ import Button from "@/components/elements/Button";
import Input from "@/components/elements/Input";
import Notice from "@/components/elements/Notice";
import ModalLayout from "@/components/layout/ModalLayout";
-import {
- TEAM_NAME_PATTERN,
- TEAM_NAME_RULE_TEXT,
-} from "@/components/teams/teamOptions";
import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
+import { MODAL_STYLE_VAR } from "@/constants/styleConstants";
+import { TEAM_NAME_PATTERN } from "@/constants/teamConstants";
import type { TTeamTree } from "@/types/teamTypes";
+import { L } from "@/locales";
interface RenameTeamModalProps {
currentName: string;
@@ -56,26 +55,26 @@ const RenameTeamModal = ({
trimmed.length > 0 && isChanged && !isInvalidFormat && !isDuplicate;
const nameError = isInvalidFormat
- ? TEAM_NAME_RULE_TEXT
+ ? L.teams.teamNameRule
: isDuplicate
- ? "같은 상위 팀에 동일한 이름이 이미 있습니다."
+ ? L.teams.dupName
: undefined;
return (
-
+
{error && {error} }
-
+
void;
- onConfirm: () => void;
-}
-
-const styles = {
- table: "w-full border-collapse text-sm",
- th: "border-border text-faint border px-3 py-1.5 text-left font-mono text-tag font-medium tracking-[0.08em]",
- td: "border-border text-muted-foreground border px-3 py-1.5",
- arrow: "text-faint px-1",
- to: "text-foreground font-semibold",
-};
-
-/**
- * RoleChangeConfirmModal is the role 변경 confirmation (SC-06 state E):
- * staged dropdown edits are listed (account · current → new) and only
- * applied on [변경하기]. Mount conditionally.
- */
-const RoleChangeConfirmModal = ({
- changes,
- onClose,
- onConfirm,
-}: RoleChangeConfirmModalProps) => {
- return (
-
-
-
다음 멤버의 권한을 변경합니다:
-
-
-
- 멤버 이름
- 권한 변경
-
-
-
- {changes.map((change) => (
-
- {change.account}
-
- {change.from}
-
- →
-
- {change.to}
-
-
- ))}
-
-
-
-
-
-
-
-
- );
-};
-
-export default RoleChangeConfirmModal;
diff --git a/frontend/src/components/teams/TeamCard.tsx b/frontend/src/components/teams/TeamCard.tsx
new file mode 100644
index 0000000..5bf567a
--- /dev/null
+++ b/frontend/src/components/teams/TeamCard.tsx
@@ -0,0 +1,65 @@
+import Button from "@/components/elements/Button";
+import { formatDate } from "@/utils/formatDate";
+import { BTN_TEXT } from "@/constants/commonConstants";
+import { L } from "@/locales";
+
+const styles = {
+ card: "border-border bg-surface rounded-lg border px-4 py-3",
+ row: "flex items-center gap-2",
+ name: "text-lg flex-1 font-semibold",
+ meta: "text-sm text-muted-foreground mt-1.5",
+};
+
+interface TeamCardProps {
+ name: string;
+ parentName: string;
+ childrenLabel: string;
+ memberCount: number;
+ createdAt?: string;
+ onRename: () => void;
+ onDelete: () => void;
+}
+
+/** TeamCard is the selected-team summary card (SC-06 no.6–8): name +
+ rename/delete actions and the parent/children/member/created meta line. */
+const TeamCard = ({
+ name,
+ parentName,
+ childrenLabel,
+ memberCount,
+ createdAt,
+ onRename,
+ onDelete,
+}: TeamCardProps) => {
+ return (
+
+
+
{name}
+
+
+
+
+ {L.teams.teamMeta(
+ parentName,
+ childrenLabel,
+ memberCount,
+ formatDate(createdAt),
+ )}
+
+
+ );
+};
+
+export default TeamCard;
diff --git a/frontend/src/components/teams/TeamMembersTable.tsx b/frontend/src/components/teams/TeamMembersTable.tsx
new file mode 100644
index 0000000..694b071
--- /dev/null
+++ b/frontend/src/components/teams/TeamMembersTable.tsx
@@ -0,0 +1,167 @@
+import Checkbox from "@/components/elements/Checkbox";
+import Dropdown from "@/components/elements/Dropdown";
+import MemberStatus from "@/components/elements/MemberStatus";
+import Pagination from "@/components/elements/Pagination";
+import Table from "@/components/table/Table";
+import TableCell from "@/components/table/TableCell";
+import TableEmptyRow from "@/components/table/TableEmptyRow";
+import TableErrorRow from "@/components/table/TableErrorRow";
+import TableFoot from "@/components/table/TableFoot";
+import TableHead from "@/components/table/TableHead";
+import TableHeaderCell from "@/components/table/TableHeaderCell";
+import TableLoadingRow from "@/components/table/TableLoadingRow";
+import TableRow from "@/components/table/TableRow";
+import { formatDate } from "@/utils/formatDate";
+import {
+ ARIA_LABELS,
+ DEFAULT_PAGE_SIZE,
+ TABLE_HEADERS,
+} from "@/constants/commonConstants";
+import { ROLE_OPTIONS } from "@/constants/teamConstants";
+import { CHIP_STATUS } from "@/constants/userConstants";
+import type { TTeamMember } from "@/types/teamTypes";
+import { L } from "@/locales";
+
+const styles = {
+ /* The detail panel is narrower than the users page — typical names
+ fit the 36% column; longer ones truncate with an ellipsis and
+ keep the full name in the title tooltip. */
+ usernameCell: "max-w-[280px] truncate cursor-default",
+ timeCell: "text-faint font-mono text-xs whitespace-nowrap",
+};
+
+interface TeamMembersTableProps {
+ members: TTeamMember[];
+ isPending: boolean;
+ isError: boolean;
+ total: number;
+ page: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ selectedIds: Set;
+ onToggleOne: (userId: string, checked: boolean) => void;
+ /** Header select-all over the current page's rows. */
+ onToggleAll: (checked: boolean) => void;
+ /** Row is highlighted while its role pick is staged (unapplied). */
+ isRoleStaged: (userId: string) => boolean;
+ /** Displayed role — the staged pick or the committed baseline. */
+ roleOf: (member: TTeamMember) => string;
+ onRoleChange: (member: TTeamMember, nextRole: string) => void;
+}
+
+/**
+ * TeamMembersTable is the SC-06 member table (no.11–13): page-scoped
+ * checkbox selection, per-row staged role dropdowns, and the fixed
+ * 10-per-page pagination. Pure view — staging/selection state lives in
+ * the parent's hooks.
+ */
+const TeamMembersTable = ({
+ members,
+ isPending,
+ isError,
+ total,
+ page,
+ totalPages,
+ onPageChange,
+ selectedIds,
+ onToggleOne,
+ onToggleAll,
+ isRoleStaged,
+ roleOf,
+ onRoleChange,
+}: TeamMembersTableProps) => {
+ const allSelected =
+ members.length > 0 && members.every((m) => selectedIds.has(m.userId));
+
+ return (
+
+
+
+ }
+ >
+
+
+
+
+ {/* Fixed column widths — auto layout would resize per
+ page's content and shift the headers while paginating. */}
+
+ {TABLE_HEADERS.memberName}
+
+
+ {TABLE_HEADERS.memberStatus}
+
+
+ {TABLE_HEADERS.roleAlt}
+
+
+ {TABLE_HEADERS.joinedAt}
+
+
+
+ {isPending ? (
+
+ ) : isError ? (
+
+ ) : total === 0 ? (
+ {L.teams.noMembers}
+ ) : (
+ members.map((member) => (
+
+
+ onToggleOne(member.userId, checked)}
+ ariaLabel={L.common.selectName(member.account)}
+ />
+
+
+ {member.username}
+
+
+
+
+
+ onRoleChange(member, next)}
+ size="sm"
+ changed={isRoleStaged(member.userId)}
+ ariaLabel={`${member.account} role`}
+ className="w-24"
+ />
+
+
+ {formatDate(member.joinedAt)}
+
+
+ ))
+ )}
+
+
+ );
+};
+
+export default TeamMembersTable;
diff --git a/frontend/src/components/teams/TeamMembersToolbar.tsx b/frontend/src/components/teams/TeamMembersToolbar.tsx
new file mode 100644
index 0000000..927444c
--- /dev/null
+++ b/frontend/src/components/teams/TeamMembersToolbar.tsx
@@ -0,0 +1,76 @@
+import Button from "@/components/elements/Button";
+import { BTN_TEXT } from "@/constants/commonConstants";
+import { L } from "@/locales";
+
+const styles = {
+ row: "flex items-center gap-2",
+ title: "text-md flex-1 font-semibold",
+ actions: "flex flex-wrap items-center gap-2",
+};
+
+interface TeamMembersToolbarProps {
+ total: number;
+ /** Staged (not yet applied) role picks — arms 초기화/업데이트. */
+ pendingCount: number;
+ /** Checked rows — arms 제거하기. */
+ selectedCount: number;
+ onResetChanges: () => void;
+ onUpdateChanges: () => void;
+ onRemove: () => void;
+ onAddMember: () => void;
+}
+
+/** TeamMembersToolbar is the 멤버 section header (SC-06 no.9–10): count +
+ the staged-change / removal / add actions. */
+const TeamMembersToolbar = ({
+ total,
+ pendingCount,
+ selectedCount,
+ onResetChanges,
+ onUpdateChanges,
+ onRemove,
+ onAddMember,
+}: TeamMembersToolbarProps) => {
+ return (
+
+
{L.teams.membersHeading(total)} {" "}
+
+ {/* Drops every staged (not yet applied) dropdown pick back to
+ its saved role — the committed savedRoles baseline stays. */}
+
+
+
+
+
+
+ );
+};
+
+export default TeamMembersToolbar;
diff --git a/frontend/src/components/teams/TreeDetailView.tsx b/frontend/src/components/teams/TreeDetailView.tsx
index c1d4068..80b745d 100644
--- a/frontend/src/components/teams/TreeDetailView.tsx
+++ b/frontend/src/components/teams/TreeDetailView.tsx
@@ -1,45 +1,53 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import Button from "@/components/elements/Button";
-import Checkbox from "@/components/elements/Checkbox";
-import Dropdown from "@/components/elements/Dropdown";
-import MemberStatus from "@/components/elements/MemberStatus";
-import Pagination from "@/components/elements/Pagination";
-import Table from "@/components/table/Table";
-import TableCell from "@/components/table/TableCell";
-import TableErrorRow from "@/components/table/TableErrorRow";
-import TableFoot from "@/components/table/TableFoot";
-import TableHead from "@/components/table/TableHead";
-import TableHeaderCell from "@/components/table/TableHeaderCell";
-import TableRow from "@/components/table/TableRow";
import AddMemberModal from "@/components/teams/AddMemberModal";
import CreateTeamModal from "@/components/teams/CreateTeamModal";
import DeleteTeamModal from "@/components/teams/DeleteTeamModal";
import MemberBatchFailureModal from "@/components/teams/MemberBatchFailureModal";
-import RemoveMembershipModal from "@/components/teams/RemoveMembershipModal";
import RenameTeamModal from "@/components/teams/RenameTeamModal";
-import RoleChangeConfirmModal from "@/components/teams/RoleChangeConfirmModal";
-import { ROLE_OPTIONS } from "@/components/teams/teamOptions";
+import TeamCard from "@/components/teams/TeamCard";
+import TeamMembersTable from "@/components/teams/TeamMembersTable";
+import TeamMembersToolbar from "@/components/teams/TeamMembersToolbar";
import TeamTree from "@/components/tree/TeamTree";
+import MembershipRemoveModal from "@/components/users/MembershipRemoveModal";
+import RoleChangeConfirmModal from "@/components/users/RoleChangeConfirmModal";
import {
useAddTeamMemberMutation,
useBulkRoleChangeMutation,
useRemoveTeamMembersMutation,
} from "@/hooks/mutations/useTeamMemberMutations";
-import {
- useCreateTeamMutation,
- useDeleteTeamMutation,
- useRenameTeamMutation,
-} from "@/hooks/mutations/useTeamMutations";
import { useTeamMembersQuery } from "@/hooks/queries/useTeamMembersQuery";
import { useTeamQuery } from "@/hooks/queries/useTeamQuery";
+import {
+ toBatchFailureRows,
+ useBatchFailureModal,
+} from "@/hooks/useBatchFailureModal";
+import { usePageScopedSelection } from "@/hooks/usePageScopedSelection";
+import {
+ useServerPagination,
+ useSyncPaginationTotal,
+} from "@/hooks/useServerPagination";
+import { useStagedRoleEdits } from "@/hooks/useStagedRoleEdits";
+import { useTeamCrud } from "@/hooks/useTeamCrud";
import { parseErrorCode } from "@/api/parseError";
-import { CHIP_STATUS } from "@/components/users/memberStatusMap";
-import { formatDate } from "@/utils/formatDate";
-import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
-import type { TTeamNode } from "@/types/commonTypes";
+import { useNoticeStore } from "@/state/store/noticeStore";
+import {
+ ancestorIds,
+ buildTeamNodes,
+ findTeamNode,
+} from "@/utils/teamHierarchy";
+import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants";
+import {
+ BTN_TEXT,
+ DEFAULT_PAGE_SIZE,
+ TABLE_HEADERS,
+} from "@/constants/commonConstants";
+import { ADD_MEMBER_REASON } from "@/constants/errorConstants";
+import { NOTICE_TEXT } from "@/constants/noticeConstants";
import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes";
-import { useNoticeStore } from "@/stores/noticeStore";
+import type { TRoleChange } from "@/types/userTypes";
+import { L } from "@/locales";
const styles = {
body: "flex min-h-[340px] flex-1",
@@ -47,18 +55,6 @@ const styles = {
side: "border-border flex w-50 flex-none flex-col gap-2.5 border-r p-3",
/* Right detail area */
main: "flex min-w-0 flex-1 flex-col gap-5 p-4",
- teamCard: "border-border bg-surface rounded-lg border px-4 py-3",
- teamCardRow: "flex items-center gap-2",
- teamName: "text-lg flex-1 font-semibold",
- teamMeta: "text-sm text-muted-foreground mt-1.5",
- membersRow: "flex items-center gap-2",
- membersTitle: "text-md flex-1 font-semibold",
- /* The detail panel is narrower than the users page — typical names
- fit the 36% column; longer ones truncate with an ellipsis and
- keep the full name in the title tooltip. */
- usernameCell: "max-w-[280px] truncate cursor-default",
- timeCell: "text-faint font-mono text-xs whitespace-nowrap",
- pendingActions: "flex flex-wrap items-center gap-2",
};
type TActiveModal =
@@ -70,39 +66,13 @@ type TActiveModal =
| "removeMembers"
| null;
-/**
- * GET /teams/tree returns flat nodes — the client builds the recursive
- * TTeamNode shape the TeamTree component consumes (API design §3).
- */
-const buildTeamNodes = (
- teams: TTeamTree,
- parentId: string | null,
-): TTeamNode[] =>
- teams
- .filter((team) => team.parentId === parentId)
- .map((team) => ({
- id: team.id,
- name: team.name,
- members: team.memberCount,
- children:
- team.childCount > 0 ? buildTeamNodes(teams, team.id) : undefined,
- }));
-
-const findTeamNode = (nodes: TTeamNode[], id: string): TTeamNode | undefined =>
- nodes.reduce(
- (found, node) =>
- found ?? (node.id === id ? node : findTeamNode(node.children ?? [], id)),
- undefined,
- );
-
-/* 10 rows per page — caps the member table height inside one screen;
- the ?size=10 GET /teams/{id}/members query param. */
-const PAGE_SIZE = 10;
-
/**
* TreeDetailView is the SC-06 트리·상세 view: team tree panel (left) +
* selected-team card and member table (right). Rendered by TeamsPage
- * when the view toggle is on 트리·상세.
+ * when the view toggle is on 트리·상세. The view composes the shared
+ * hooks (selection, pagination, staged role edits, team CRUD, batch
+ * failures) and hands rendering to TeamCard/TeamMembersToolbar/
+ * TeamMembersTable.
*/
interface TreeDetailViewProps {
/** Flat GET /teams/tree nodes — owned by TeamsPage. Always non-empty
@@ -117,43 +87,37 @@ interface TreeDetailViewProps {
onSelectTeam: (teamId: string) => void;
}
-/** Ancestor ids of a team — expanded so a selection handed off from
- the org chart is actually visible in the tree. */
-const ancestorIds = (teams: TTeamTree, teamId: string): string[] => {
- const flatById = new Map(teams.map((team) => [team.id, team]));
- const ids: string[] = [];
- let parentId = flatById.get(teamId)?.parentId;
- while (parentId) {
- ids.push(parentId);
- parentId = flatById.get(parentId)?.parentId;
- }
- return ids;
-};
-
const TreeDetailView = ({
teams,
teamSearch,
selectedTeamId,
onSelectTeam,
}: TreeDetailViewProps) => {
- const flatById = new Map(teams.map((t) => [t.id, t]));
- const teamNodes = buildTeamNodes(teams, null);
+ /* Derived once per teams array — this component re-renders on every
+ keystroke/checkbox/staged edit, and the tree build must not re-run
+ for those (OrgChart applies the same rule). */
+ const flatById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]);
+ const teamNodes = useMemo(() => buildTeamNodes(teams), [teams]);
/* Fallback selection — the first top-level team (SC-06 entry rule). */
- const defaultTeam = findTeamNode(teamNodes, "t_a") ?? teamNodes[0];
-
+ const defaultTeam = teamNodes[0];
const selectedTeam = findTeamNode(teamNodes, selectedTeamId) ?? defaultTeam;
- const [selectedIds, setSelectedIds] = useState>(new Set());
- const [page, setPage] = useState(1);
- /* Role edits are staged (SC-06): dropdown picks collect here and only
- apply on [변경사항 업데이트]. savedRoles is the committed baseline
- (stands in for the PUT /teams/{id}/members batch until wired). */
- const [pendingRoles, setPendingRoles] = useState<
- Map
- >(new Map());
- const [savedRoles, setSavedRoles] = useState>(
- new Map(),
- );
+ const { selectedIds, toggleOne, toggleAll, clearSelection } =
+ usePageScopedSelection();
+ const { page, totalPages, setPage, resetPage, syncTotal } =
+ useServerPagination();
+ const {
+ pendingRoles,
+ baseRole,
+ stageRole,
+ resetStaged,
+ resetAll,
+ applyAll,
+ reconcileBatch,
+ } = useStagedRoleEdits();
+ const { batchFailures, showBatchFailures, closeBatchFailures } =
+ useBatchFailureModal();
+ const showNotice = useNoticeStore((state) => state.showNotice);
/* Switching teams must not leak the prior team's member-table state:
without this, `page` can point past the new team's last page (no
@@ -166,195 +130,71 @@ const TreeDetailView = ({
target (e.g. when the prop doesn't resolve and falls back to
defaultTeam). */
useEffect(() => {
- setPage(1);
- setSelectedIds(new Set());
- setPendingRoles(new Map());
- setSavedRoles(new Map());
+ resetPage();
+ clearSelection();
+ resetAll();
}, [selectedTeam.id]);
const { data: detail } = useTeamQuery(selectedTeam.id);
- const membersQuery = useTeamMembersQuery(selectedTeam.id, page, PAGE_SIZE);
+ const membersQuery = useTeamMembersQuery(
+ selectedTeam.id,
+ page,
+ DEFAULT_PAGE_SIZE,
+ );
const members = membersQuery.data?.items ?? [];
const total = membersQuery.data?.total ?? 0;
- const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ useSyncPaginationTotal(syncTotal, total);
const addMember = useAddTeamMemberMutation(selectedTeam.id);
const bulkRole = useBulkRoleChangeMutation(selectedTeam.id);
const removeMembers = useRemoveTeamMembersMutation(selectedTeam.id);
- const createTeam = useCreateTeamMutation();
- const renameTeam = useRenameTeamMutation(selectedTeam.id);
- const deleteTeam = useDeleteTeamMutation(selectedTeam.id);
+ /* Selected-team card meta — detail query first, flat tree row as the
+ immediate fallback while the detail loads. */
const flatTeam = flatById.get(selectedTeam.id);
const parentName = detail?.parentId
- ? (flatById.get(detail.parentId)?.name ?? "없음")
+ ? (flatById.get(detail.parentId)?.name ?? L.common.none)
: flatTeam?.parentId
- ? (flatById.get(flatTeam.parentId)?.name ?? "없음")
- : "없음";
+ ? (flatById.get(flatTeam.parentId)?.name ?? L.common.none)
+ : L.common.none;
const childCount = detail?.children.length ?? flatTeam?.childCount ?? 0;
- const childrenLabel = childCount ? `${childCount}개` : "없음";
+ const childrenLabel = childCount
+ ? L.teams.countItems(childCount)
+ : L.common.none;
const memberCount = detail?.memberCount ?? selectedTeam.members;
- /* Select-all is page-scoped; selections persist across page moves. */
- const allSelected =
- members.length > 0 && members.every((m) => selectedIds.has(m.userId));
-
- const toggleAll = (checked: boolean) =>
- setSelectedIds((prev) => {
- const next = new Set(prev);
- members.forEach((m) =>
- checked ? next.add(m.userId) : next.delete(m.userId),
- );
- return next;
- });
-
- const toggleOne = (userId: string, checked: boolean) =>
- setSelectedIds((prev) => {
- const next = new Set(prev);
- if (checked) next.add(userId);
- else next.delete(userId);
- return next;
- });
-
- const showNotice = useNoticeStore((state) => state.showNotice);
-
- const baseRole = (userId: string, fallback: TTeamMemberRole) =>
- savedRoles.get(userId) ?? fallback;
-
- const handleRoleChange = (
- userId: string,
- fallback: TTeamMemberRole,
- nextRole: string,
- ) =>
- setPendingRoles((prev) => {
- const next = new Map(prev);
- if (nextRole === baseRole(userId, fallback)) next.delete(userId);
- else next.set(userId, nextRole as TTeamMemberRole);
- return next;
- });
-
- const applyRoleChanges = () => {
- setSavedRoles((prev) => new Map([...prev, ...pendingRoles]));
- setPendingRoles(new Map());
- showNotice(
- MODAL_TITLES.roleChange,
- "변경사항이 저장되었습니다.",
- "success",
- );
- };
-
/* Modals (SC-07~10 + SC-06 state E). All confirm handlers below call
their real mutations. */
const [activeModal, setActiveModal] = useState(null);
const closeModal = () => setActiveModal(null);
- /* Team CRUD (create/rename/delete) inline error — reset whenever a
- modal opens or closes so a stale error from a prior attempt never
- leaks into a fresh one. */
- const [teamError, setTeamError] = useState(null);
+ const {
+ teamError,
+ clearTeamError,
+ handleCreate,
+ handleRename,
+ handleDelete,
+ } = useTeamCrud({
+ teamId: selectedTeam.id,
+ onDone: closeModal,
+ onDeleted: () =>
+ onSelectTeam(
+ teams.find((t) => t.parentId === null && t.id !== selectedTeam.id)
+ ?.id ?? "",
+ ),
+ });
+ /* Team CRUD inline error — reset whenever a modal opens or closes so a
+ stale error from a prior attempt never leaks into a fresh one. */
const openTeamModal = (modal: TActiveModal) => {
- setTeamError(null);
+ clearTeamError();
setActiveModal(modal);
};
const closeTeamModal = () => {
- setTeamError(null);
+ clearTeamError();
closeModal();
};
- const TEAM_REASON: Record = {
- TEAM_NAME_DUPLICATE: "같은 상위 팀에 동일한 이름이 이미 있습니다.",
- TEAM_NAME_INVALID: "팀 이름 형식이 올바르지 않습니다.",
- TEAM_HAS_CHILDREN: "하위 팀이 있어 삭제할 수 없습니다.",
- };
-
- /* Partial-failure surface for the two batch endpoints (role change,
- remove): non-null opens MemberBatchFailureModal listing exactly
- what failed and why (API design — partial success is not an error). */
- const [batchFailures, setBatchFailures] = useState<
- { account: string; reason: string }[] | null
- >(null);
- const BATCH_REASON: Record = {
- USER_NOT_FOUND: "사용자를 찾을 수 없습니다",
- NOT_TEAM_MEMBER: "팀 멤버가 아닙니다",
- };
- // Any other code (e.g. a transient INTERNAL) shows a generic retry message
- // instead of leaking the raw backend code into the failure modal.
- const BATCH_REASON_FALLBACK = "처리에 실패했습니다. 다시 시도해 주세요.";
- const accountOf = (userId: string) =>
- members.find((m) => m.userId === userId)?.account ?? userId;
const [addError, setAddError] = useState(null);
- const ADD_REASON: Record = {
- ALREADY_TEAM_MEMBER: "이미 초대된 사용자입니다.",
- USER_NOT_FOUND: "등록되지 않은 계정입니다.",
- CANNOT_INVITE_ADMIN: "콘솔 관리자 계정은 추가할 수 없습니다.",
- MAIL_UPSTREAM_ERROR: "초대 코드 전송에 실패했습니다. 다시 시도해 주세요.",
- };
-
- const roleChanges = [...pendingRoles.entries()].map(([userId, to]) => {
- const member = members.find((m) => m.userId === userId);
- return {
- account: member?.account ?? userId,
- from: baseRole(userId, member?.role ?? "read"),
- to,
- };
- });
-
- const handleCreate = (name: string, parentId: string | null) => {
- setTeamError(null);
- createTeam.mutate(
- { name, parentId },
- {
- onSuccess: () => {
- closeModal();
- showNotice("팀 생성", "팀이 생성되었습니다.", "success");
- },
- onError: async (res) => {
- const code = await parseErrorCode(res);
- setTeamError(TEAM_REASON[code] ?? "팀 생성에 실패했습니다.");
- },
- },
- );
- };
- const handleRename = (name: string) => {
- setTeamError(null);
- renameTeam.mutate(
- { name },
- {
- onSuccess: () => {
- closeModal();
- showNotice("팀 이름 변경", "팀 이름이 변경되었습니다.", "success");
- },
- onError: async (res) => {
- const code = await parseErrorCode(res);
- setTeamError(TEAM_REASON[code] ?? "이름 변경에 실패했습니다.");
- },
- },
- );
- };
- const handleDelete = (
- action: "purge" | "transfer",
- targetTeamId?: string,
- ) => {
- setTeamError(null);
- deleteTeam.mutate(
- { memoryAction: action, targetTeamId },
- {
- onSuccess: () => {
- closeModal();
- showNotice("팀 삭제", "팀이 삭제되었습니다.", "success", () => {
- onSelectTeam(
- teams.find((t) => t.parentId === null && t.id !== selectedTeam.id)
- ?.id ?? "",
- );
- });
- },
- onError: async (res) => {
- const code = await parseErrorCode(res);
- setTeamError(TEAM_REASON[code] ?? "팀 삭제에 실패했습니다.");
- },
- },
- );
- };
const handleInvite = (account: string, role: string, username: string) => {
setAddError(null);
addMember.mutate(
@@ -362,100 +202,88 @@ const TreeDetailView = ({
{
onSuccess: () => {
closeModal();
- showNotice("멤버 추가", "멤버를 추가했습니다.", "success");
+ showNotice(
+ NOTICE_TEXT.addTeamMember.title,
+ NOTICE_TEXT.addTeamMember.success,
+ "success",
+ );
},
onError: async (res) => {
const code = await parseErrorCode(res);
- setAddError(ADD_REASON[code] ?? "멤버 추가에 실패했습니다.");
+ setAddError(ADD_MEMBER_REASON[code] ?? L.teams.addMemberFailed);
},
},
);
};
- const handleRoleConfirm = () => {
+
+ const accountOf = (userId: string) =>
+ members.find((m) => m.userId === userId)?.account ?? userId;
+
+ /* Staged picks as confirm-modal rows (TRoleChange: label = account). */
+ const roleChanges: TRoleChange[] = [...pendingRoles.entries()].map(
+ ([userId, to]) => {
+ const member = members.find((m) => m.userId === userId);
+ return {
+ label: member?.account ?? userId,
+ from: baseRole(userId, member?.role ?? TEAM_MEMBER_ROLE.read),
+ to,
+ };
+ },
+ );
+
+ /* The confirm modal owns the E-1/E-2 result view: a resolved promise
+ shows the in-modal success message, a rejected one the failure
+ message ([닫기] alone remains). Partial failures additionally open
+ the batch-failure modal, mirroring the SC-13 drawer flow. */
+ const handleRoleConfirm = async () => {
const updates = [...pendingRoles.entries()].map(([userId, role]) => ({
userId,
role,
}));
- bulkRole.mutate(
- { updates },
- {
- onSuccess: (result) => {
- closeModal();
- if (result.failed.length > 0) {
- /* Only clear staging for what actually succeeded — keep the
- failed entries pending so the user can retry them. */
- const failedIds = new Set(result.failed.map((f) => f.id));
- setSavedRoles(
- (prev) =>
- new Map([
- ...prev,
- ...[...pendingRoles.entries()].filter(
- ([userId]) => !failedIds.has(userId),
- ),
- ]),
- );
- setPendingRoles(
- (prev) =>
- new Map([...prev].filter(([userId]) => failedIds.has(userId))),
- );
- setBatchFailures(
- result.failed.map((f) => ({
- account: accountOf(f.id),
- reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK,
- })),
- );
- } else {
- applyRoleChanges();
- }
- },
- onError: () => {
- closeModal();
- showNotice(
- MODAL_TITLES.roleChange,
- "권한 변경에 실패했습니다.",
- "error",
- );
- },
- },
- );
+ const result = await bulkRole.mutateAsync({ updates });
+ if (result.failed.length > 0) {
+ reconcileBatch(new Set(result.failed.map((f) => f.id)));
+ showBatchFailures(
+ toBatchFailureRows(
+ result.failed,
+ accountOf,
+ () => L.common.processFailed,
+ ),
+ );
+ } else {
+ applyAll();
+ }
};
- const handleRemoveMembers = () => {
+
+ /* The remove modal closes itself on resolve and swaps to its failure
+ view on reject — only the full-success notice and the partial-failure
+ modal are driven from here. */
+ const handleRemoveMembers = async () => {
const ids = [...selectedIds];
- removeMembers.mutate(ids, {
- onSuccess: (result) => {
- closeModal();
- setSelectedIds(new Set());
- if (result.failed.length > 0) {
- setBatchFailures(
- result.failed.map((f) => ({
- account: accountOf(f.id),
- reason: BATCH_REASON[f.code] ?? f.code,
- })),
- );
- } else {
- showNotice(
- MODAL_TITLES.removeMembership,
- "멤버십이 제거되었습니다.",
- "success",
- );
- }
- },
- onError: () => {
- closeModal();
- showNotice(
- MODAL_TITLES.removeMembership,
- "멤버십 제거에 실패했습니다.",
- "error",
- );
- },
- });
+ const result = await removeMembers.mutateAsync(ids);
+ clearSelection();
+ if (result.failed.length > 0) {
+ showBatchFailures(
+ toBatchFailureRows(result.failed, accountOf, (code) => code),
+ );
+ } else {
+ showNotice(
+ NOTICE_TEXT.removeMembership.title,
+ NOTICE_TEXT.removeMembership.success,
+ "success",
+ );
+ }
};
- /* SC-14 payload: the checked members' account · current role. */
+ /* SC-14 payload: the checked members' account × this team · current
+ role (TMembershipRemoveTarget — the SC-06 entry is members × the
+ one selected team). */
const membershipRemovals = members
.filter((member) => selectedIds.has(member.userId))
.map((member) => ({
account: member.account,
+ teamId: selectedTeam.id,
+ teamName: selectedTeam.name,
role:
pendingRoles.get(member.userId) ?? baseRole(member.userId, member.role),
}));
@@ -464,7 +292,7 @@ const TreeDetailView = ({
{/* Left panel — create + tree (SC-06 no.3–5); search lives in the
TeamsPage header */}
-
+
onSelectTeam(node.id)}
- defaultExpandedIds={[
- "t_a",
- "t_e",
- ...ancestorIds(teams, selectedTeam.id),
- ]}
+ defaultExpandedIds={ancestorIds(flatById, selectedTeam.id)}
className="-mx-1 flex-1"
/>
{/* Detail area — selected team card + members section (SC-06 no.6–13) */}
-
-
-
- {detail?.name ?? selectedTeam.name}
-
- openTeamModal("rename")}
- />
- openTeamModal("delete")}
- />
-
-
- 상위 팀: {parentName} | 하위 팀: {childrenLabel} | 멤버:{" "}
- {memberCount}명 | 생성일: {formatDate(detail?.createdAt)}
-
-
+
openTeamModal("rename")}
+ onDelete={() => openTeamModal("delete")}
+ />
-
-
멤버 ({total}) {" "}
-
- {/* Drops every staged (not yet applied) dropdown pick back to
- its saved role — the committed savedRoles baseline stays. */}
- setPendingRoles(new Map())}
- />
- setActiveModal("roleConfirm")}
- />
- setActiveModal("removeMembers")}
- />
- setActiveModal("addMember")}
- />
-
-
+ setActiveModal("roleConfirm")}
+ onRemove={() => setActiveModal("removeMembers")}
+ onAddMember={() => setActiveModal("addMember")}
+ />
-
-
-
+
+ toggleAll(
+ members.map((m) => m.userId),
+ checked,
+ )
+ }
+ isRoleStaged={(userId) => pendingRoles.has(userId)}
+ roleOf={(member) =>
+ pendingRoles.get(member.userId) ??
+ baseRole(member.userId, member.role)
}
- >
-
-
-
-
- {/* Fixed column widths — auto layout would resize per
- page's content and shift the headers while paginating. */}
- 멤버 이름
- 멤버 상태
- 역할
- 합류일
-
-
- {membersQuery.isPending ? (
-
-
- 불러오는 중…
-
-
- ) : membersQuery.isError ? (
-
- ) : total === 0 ? (
-
-
- 멤버가 없습니다.
-
-
- ) : (
- members.map((member) => (
-
-
- toggleOne(member.userId, checked)}
- ariaLabel={`${member.account} 선택`}
- />
-
-
- {member.username}
-
-
-
-
-
-
- handleRoleChange(member.userId, member.role, next)
- }
- size="sm"
- changed={pendingRoles.has(member.userId)}
- ariaLabel={`${member.account} role`}
- className="w-24"
- />
-
-
- {formatDate(member.joinedAt)}
-
-
- ))
- )}
-
-
+ onRoleChange={(member, next) =>
+ stageRole(member.userId, member.role, next)
+ }
+ />
{/* Modals — mounted on demand so each opens with fresh state */}
@@ -698,15 +401,16 @@ const TreeDetailView = ({
)}
{activeModal === "roleConfirm" && (
)}
{activeModal === "removeMembers" && (
-
@@ -714,7 +418,7 @@ const TreeDetailView = ({
{batchFailures && (
setBatchFailures(null)}
+ onClose={closeBatchFailures}
/>
)}
diff --git a/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx b/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx
index 1720904..20edabb 100644
--- a/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx
+++ b/frontend/src/components/teams/__tests__/TreeDetailView.test.tsx
@@ -7,9 +7,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import TreeDetailView from "@/components/teams/TreeDetailView";
import * as teamAPIs from "@/api/teamAPIs";
import * as teamMemberAPIs from "@/api/teamMemberAPIs";
+import { useNoticeStore } from "@/state/store/noticeStore";
import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
import type { TTeamMember, TTeamTree } from "@/types/teamTypes";
-import { useNoticeStore } from "@/stores/noticeStore";
const jsonRes = (body: unknown) =>
({ ok: true, json: async () => body }) as unknown as Response;
@@ -435,7 +435,9 @@ describe("TreeDetailView", () => {
await user.click(screen.getByRole("radio", { name: /팀 내 기억 삭제/ }));
await user.type(
screen.getByLabelText("확인 - 삭제할 팀명 입력"),
- "Platform",
+ // Team names are demo-localized (localizeTeamName) — the confirm input
+ // must match the displayed (Korean, under the test's ko pin) name.
+ "플랫폼",
);
/* Two "팀 삭제" buttons exist once the confirm modal opens (the card
trigger + the modal's confirm) — the confirm one is the last. */
@@ -554,7 +556,6 @@ describe("TreeDetailView", () => {
vi.spyOn(teamMemberAPIs, "bulkRoleChange").mockResolvedValue(
jsonRes({ succeeded: ["u_1"], failed: [] }),
);
- const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice");
renderView();
await screen.findByText("김철수");
await user.click(screen.getByLabelText("kim@corp.com role"));
@@ -563,13 +564,14 @@ describe("TreeDetailView", () => {
screen.getByRole("button", { name: BTN_TEXT.updateChanges }),
);
await user.click(screen.getByRole("button", { name: BTN_TEXT.change }));
- await waitFor(() =>
- expect(showNoticeSpy).toHaveBeenCalledWith(
- MODAL_TITLES.roleChange,
- "변경사항이 저장되었습니다.",
- "success",
- ),
- );
+ /* SC-06 E-1: the result renders inside the confirm modal; [닫기]
+ alone remains. */
+ expect(
+ await screen.findByText("권한이 변경되었습니다."),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: BTN_TEXT.change }),
+ ).not.toBeInTheDocument();
});
it("resets every staged role pick back to the saved value via 변경사항 초기화", async () => {
@@ -634,7 +636,6 @@ describe("TreeDetailView", () => {
status: 500,
json: async () => ({ code: "INTERNAL", message: "x" }),
} as unknown as Response);
- const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice");
renderView();
await screen.findByText("김철수");
await user.click(screen.getByLabelText("kim@corp.com role"));
@@ -643,13 +644,10 @@ describe("TreeDetailView", () => {
screen.getByRole("button", { name: BTN_TEXT.updateChanges }),
);
await user.click(screen.getByRole("button", { name: BTN_TEXT.change }));
- await waitFor(() =>
- expect(showNoticeSpy).toHaveBeenCalledWith(
- MODAL_TITLES.roleChange,
- "권한 변경에 실패했습니다.",
- "error",
- ),
- );
+ /* SC-06 E-2: the failure message renders inside the confirm modal. */
+ expect(
+ await screen.findByText("권한 변경에 실패했습니다. 다시 시도해 주세요."),
+ ).toBeInTheDocument();
});
it("shows a success notice when a full-success member removal completes", async () => {
@@ -710,7 +708,6 @@ describe("TreeDetailView", () => {
status: 500,
json: async () => ({ code: "INTERNAL", message: "x" }),
} as unknown as Response);
- const showNoticeSpy = vi.spyOn(useNoticeStore.getState(), "showNotice");
renderView();
await screen.findByText("김철수");
await user.click(
@@ -721,13 +718,12 @@ describe("TreeDetailView", () => {
name: BTN_TEXT.remove,
});
await user.click(confirmButtons[confirmButtons.length - 1]);
- await waitFor(() =>
- expect(showNoticeSpy).toHaveBeenCalledWith(
- MODAL_TITLES.removeMembership,
- "멤버십 제거에 실패했습니다.",
- "error",
+ /* The remove modal swaps to its in-modal failure view (state B). */
+ expect(
+ await screen.findByText(
+ "멤버십 제거에 실패했습니다. 다시 시도해 주세요.",
),
- );
+ ).toBeInTheDocument();
});
it("shows the mapped inline error when deleting a childless team hits a server conflict", async () => {
@@ -758,7 +754,9 @@ describe("TreeDetailView", () => {
await user.click(screen.getByRole("radio", { name: /팀 내 기억 삭제/ }));
await user.type(
screen.getByLabelText("확인 - 삭제할 팀명 입력"),
- "Platform",
+ // Team names are demo-localized (localizeTeamName) — the confirm input
+ // must match the displayed (Korean, under the test's ko pin) name.
+ "플랫폼",
);
const confirmButtons = screen.getAllByRole("button", {
name: BTN_TEXT.deleteTeam,
diff --git a/frontend/src/components/teams/__tests__/teamModals.test.tsx b/frontend/src/components/teams/__tests__/teamModals.test.tsx
index 75ea277..9f0d39e 100644
--- a/frontend/src/components/teams/__tests__/teamModals.test.tsx
+++ b/frontend/src/components/teams/__tests__/teamModals.test.tsx
@@ -5,9 +5,9 @@ import { describe, expect, it, vi } from "vitest";
import AddMemberModal from "@/components/teams/AddMemberModal";
import CreateTeamModal from "@/components/teams/CreateTeamModal";
import DeleteTeamModal from "@/components/teams/DeleteTeamModal";
-import RemoveMembershipModal from "@/components/teams/RemoveMembershipModal";
import RenameTeamModal from "@/components/teams/RenameTeamModal";
-import RoleChangeConfirmModal from "@/components/teams/RoleChangeConfirmModal";
+import MembershipRemoveModal from "@/components/users/MembershipRemoveModal";
+import RoleChangeConfirmModal from "@/components/users/RoleChangeConfirmModal";
import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
import type { TTeamTree } from "@/types/teamTypes";
@@ -330,20 +330,28 @@ describe("AddMemberModal", () => {
});
});
-describe("RemoveMembershipModal", () => {
- it("lists removals, always shows the sub-team notice, confirms", async () => {
+describe("MembershipRemoveModal (SC-06 entry)", () => {
+ it("lists removals with the sub-team notice and confirms", async () => {
const user = userEvent.setup();
- const onConfirm = vi.fn();
+ const onConfirm = vi.fn().mockResolvedValue(undefined);
render(
- {}}
onConfirm={onConfirm}
/>,
);
expect(screen.getByText(MODAL_TITLES.removeMembership)).toBeInTheDocument();
expect(screen.getByText("k@corp.com")).toBeInTheDocument();
+ expect(screen.getByText("백엔드")).toBeInTheDocument();
expect(screen.getByText(/하위 팀 소속은 유지됩니다/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: BTN_TEXT.remove }));
@@ -351,13 +359,14 @@ describe("RemoveMembershipModal", () => {
});
});
-describe("RoleChangeConfirmModal", () => {
- it("lists staged changes and confirms", async () => {
+describe("RoleChangeConfirmModal (SC-06 entry)", () => {
+ it("lists staged changes and shows the in-modal result after 변경하기", async () => {
const user = userEvent.setup();
- const onConfirm = vi.fn();
+ const onConfirm = vi.fn().mockResolvedValue(undefined);
render(
{}}
onConfirm={onConfirm}
/>,
@@ -367,5 +376,12 @@ describe("RoleChangeConfirmModal", () => {
await user.click(screen.getByRole("button", { name: BTN_TEXT.change }));
expect(onConfirm).toHaveBeenCalled();
+ /* E-1: the result renders inside the modal; [닫기] alone remains. */
+ expect(
+ await screen.findByText("권한이 변경되었습니다."),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: BTN_TEXT.change }),
+ ).not.toBeInTheDocument();
});
});
diff --git a/frontend/src/components/teams/teamHierarchy.ts b/frontend/src/components/teams/teamHierarchy.ts
deleted file mode 100644
index b5d5d3c..0000000
--- a/frontend/src/components/teams/teamHierarchy.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { TTeamTree } from "@/types/teamTypes";
-
-/**
- * Team-tree lookups over a flat `TTeamTree` — shared by the invite preview
- * (SC-12 no.3) and the membership-removal sub-team notice (SC-14 no.2).
- * Pure functions over the tree passed in (from `useTeamsTreeQuery`); trees
- * are small, so no memoized id-map is kept at module scope.
- */
-
-/** Team name for `teamId`, or the id itself if the team is unknown. */
-export const getTeamName = (teams: TTeamTree, teamId: string): string =>
- teams.find((team) => team.id === teamId)?.name ?? teamId;
-
-/** All descendant ids of a team, in depth-first tree order. */
-export const getTeamDescendantIds = (
- teams: TTeamTree,
- teamId: string,
-): string[] =>
- (teams.find((team) => team.id === teamId)?.childrenIds ?? []).flatMap(
- (childId) => [childId, ...getTeamDescendantIds(teams, childId)],
- );
diff --git a/frontend/src/components/tree/TeamTree.tsx b/frontend/src/components/tree/TeamTree.tsx
index f9db366..9acd0f5 100644
--- a/frontend/src/components/tree/TeamTree.tsx
+++ b/frontend/src/components/tree/TeamTree.tsx
@@ -3,12 +3,13 @@ import { useMemo, useState } from "react";
import Feedback from "@/components/elements/Feedback";
import TreeNode from "@/components/tree/TreeNode";
import { cn } from "@/utils/cn";
-import type { TTeamNode } from "@/types/commonTypes";
+import type { TTeamViewNode } from "@/types/teamTypes";
+import { L } from "@/locales";
interface TeamTreeProps {
- teams: TTeamNode[];
+ teams: TTeamViewNode[];
selectedId?: string;
- onSelect: (node: TTeamNode) => void;
+ onSelect: (node: TTeamViewNode) => void;
/** Case-insensitive filter — a node matches if it or a descendant matches. */
query?: string;
defaultExpandedIds?: string[];
@@ -18,7 +19,7 @@ interface TeamTreeProps {
}
interface TFilterResult {
- nodes: TTeamNode[];
+ nodes: TTeamViewNode[];
/** Nodes kept only because a descendant matched — auto-expanded so the
match is actually visible (2차-B spec: ancestors stay open). */
autoExpandIds: string[];
@@ -29,19 +30,21 @@ interface TFilterResult {
* whole subtree (context); otherwise only children leading to a match
* survive. `query` must already be trimmed + lowercased.
*/
-const filterTree = (teams: TTeamNode[], query: string): TFilterResult => {
+const filterTree = (teams: TTeamViewNode[], query: string): TFilterResult => {
const autoExpandIds: string[] = [];
- const prune = (node: TTeamNode): TTeamNode | null => {
+ const prune = (node: TTeamViewNode): TTeamViewNode | null => {
if (node.name.toLocaleLowerCase().includes(query)) return node;
const children = (node.children ?? [])
.map(prune)
- .filter((child): child is TTeamNode => child !== null);
+ .filter((child): child is TTeamViewNode => child !== null);
if (children.length === 0) return null;
autoExpandIds.push(node.id);
return { ...node, children };
};
return {
- nodes: teams.map(prune).filter((node): node is TTeamNode => node !== null),
+ nodes: teams
+ .map(prune)
+ .filter((node): node is TTeamViewNode => node !== null),
autoExpandIds,
};
};
@@ -62,8 +65,8 @@ const TeamTree = ({
onSelect,
query = "",
defaultExpandedIds = [],
- emptyTitle = "검색 결과가 없습니다.",
- emptyDescription = "팀 이름을 다시 확인해 주세요.",
+ emptyTitle = L.common.noResults,
+ emptyDescription = L.teams.checkTeamName,
className,
}: TeamTreeProps) => {
const [expanded, setExpanded] = useState(() => new Set(defaultExpandedIds));
diff --git a/frontend/src/components/tree/TeamTreeFooter.tsx b/frontend/src/components/tree/TeamTreeFooter.tsx
index a203b6f..93aee0a 100644
--- a/frontend/src/components/tree/TeamTreeFooter.tsx
+++ b/frontend/src/components/tree/TeamTreeFooter.tsx
@@ -1,5 +1,5 @@
import { cn } from "@/utils/cn";
-import type { TTeamNode } from "@/types/commonTypes";
+import type { TTeamViewNode } from "@/types/teamTypes";
const styles = {
wrap: "bg-mint/[2%] grid grid-cols-[auto_1fr_auto] items-center gap-2.5 border-t px-4 py-3",
@@ -9,7 +9,7 @@ const styles = {
};
interface TeamTreeFooterProps {
- node: TTeamNode;
+ node: TTeamViewNode;
className?: string;
}
diff --git a/frontend/src/components/tree/TreeNode.tsx b/frontend/src/components/tree/TreeNode.tsx
index 76bda3a..f4805e7 100644
--- a/frontend/src/components/tree/TreeNode.tsx
+++ b/frontend/src/components/tree/TreeNode.tsx
@@ -3,7 +3,8 @@ import type { CSSProperties } from "react";
import IconMinus from "@/components/icons/IconMinus";
import IconPlus from "@/components/icons/IconPlus";
import { cn } from "@/utils/cn";
-import type { TTeamNode } from "@/types/commonTypes";
+import type { TTeamViewNode } from "@/types/teamTypes";
+import { L } from "@/locales";
const styles = {
row: "grid grid-cols-[24px_1fr] items-center rounded-sm pl-[calc(var(--tree-depth)*18px)] transition-[background-color] duration-[160ms]",
@@ -16,11 +17,11 @@ const styles = {
};
interface TreeNodeProps {
- node: TTeamNode;
+ node: TTeamViewNode;
depth: number;
selectedId?: string;
expanded: Set;
- onSelect: (node: TTeamNode) => void;
+ onSelect: (node: TTeamViewNode) => void;
onToggle: (id: string) => void;
/** Active search text (trimmed + lowercased) — emphasizes the match. */
highlight?: string;
@@ -81,7 +82,11 @@ const TreeNode = ({
onToggle(node.id)}
>
diff --git a/frontend/src/components/tree/__tests__/TeamTree.test.tsx b/frontend/src/components/tree/__tests__/TeamTree.test.tsx
index 6c2f681..eeb4022 100644
--- a/frontend/src/components/tree/__tests__/TeamTree.test.tsx
+++ b/frontend/src/components/tree/__tests__/TeamTree.test.tsx
@@ -3,9 +3,9 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import TeamTree from "@/components/tree/TeamTree";
-import type { TTeamNode } from "@/types/commonTypes";
+import type { TTeamViewNode } from "@/types/teamTypes";
-const TEAMS: TTeamNode[] = [
+const TEAMS: TTeamViewNode[] = [
{
id: "platform",
name: "Platform",
@@ -71,7 +71,7 @@ describe("TeamTree", () => {
});
it("prunes non-matching siblings but keeps the whole subtree of a self-match", () => {
- const forest: TTeamNode[] = [
+ const forest: TTeamViewNode[] = [
{
id: "a",
name: "Alpha",
diff --git a/frontend/src/components/update/UpdateFloatingCard.tsx b/frontend/src/components/update/UpdateFloatingCard.tsx
index 573d808..be9704d 100644
--- a/frontend/src/components/update/UpdateFloatingCard.tsx
+++ b/frontend/src/components/update/UpdateFloatingCard.tsx
@@ -9,7 +9,9 @@ import {
useUpdateQuery,
} from "@/hooks/queries/useUpdateQuery";
import { reloadPage } from "@/utils/reloadPage";
+import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants";
import { BTN_TEXT } from "@/constants/commonConstants";
+import { L } from "@/locales";
const DISMISSED_KEY_PREFIX = "runeconsole.system-update.dismissed:";
const QUEUED_TARGET_KEY = "runeconsole.system-update.queued-target";
@@ -88,7 +90,8 @@ const UpdateFloatingCard = () => {
if (!status || !queuedTarget) return;
const installed = status.currentVersion === queuedTarget;
const sameJobSucceeded =
- status.targetVersion === queuedTarget && status.state === "succeeded";
+ status.targetVersion === queuedTarget &&
+ status.state === SYSTEM_UPDATE_STATE.succeeded;
if (!installed && !sameJobSucceeded) return;
// Clear first: the next SPA must not enter a reload loop if the helper
@@ -103,7 +106,9 @@ const UpdateFloatingCard = () => {
const targetVersion = status.targetVersion;
const serverActive = isSystemUpdateActive(status.state);
const busy = serverActive || updateMutation.isPending;
- const failed = !busy && (status.state === "failed" || updateMutation.isError);
+ const failed =
+ !busy &&
+ (status.state === SYSTEM_UPDATE_STATE.failed || updateMutation.isError);
const initiallyEligible = status.capable && status.updateAvailable;
if (!busy && !initiallyEligible) return null;
@@ -129,10 +134,10 @@ const UpdateFloatingCard = () => {
};
const title = busy
- ? "콘솔을 업데이트하는 중입니다"
+ ? L.workspace.updatingTitle
: failed
- ? "업데이트에 실패했습니다"
- : "새 버전이 출시되었습니다";
+ ? L.workspace.updateFailedTitle
+ : L.workspace.newVersionTitle;
return (
{
>
- {status.state === "running"
- ? "백업 및 업데이트를 진행하고 있습니다…"
- : "업데이트를 준비하고 있습니다…"}
+ {status.state === SYSTEM_UPDATE_STATE.running
+ ? L.workspace.updateRunning
+ : L.workspace.updatePreparing}
) : failed ? (
-
- 업데이트를 완료하지 못했습니다. 상태를 확인한 뒤 다시 시도해 주세요.
-
+
{L.workspace.updateFailedBody}
) : (
-
- 콘솔이 재시작되는 동안 RUNE 사용이 일시적으로 중단될 수 있습니다.
-
+
{L.workspace.updateRestartNotice}
)}
{!busy &&
diff --git a/frontend/src/components/users/CancelInvitationModal.tsx b/frontend/src/components/users/CancelInvitationModal.tsx
index dc05f8e..de152ef 100644
--- a/frontend/src/components/users/CancelInvitationModal.tsx
+++ b/frontend/src/components/users/CancelInvitationModal.tsx
@@ -3,6 +3,8 @@ import { useState } from "react";
import Button from "@/components/elements/Button";
import ModalLayout from "@/components/layout/ModalLayout";
import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
+import { MODAL_STYLE_VAR } from "@/constants/styleConstants";
+import { L } from "@/locales";
interface CancelInvitationModalProps {
account: string;
@@ -37,11 +39,11 @@ const CancelInvitationModal = ({
return (
-
- {account}의 미사용 초대 코드가 모두 만료됩니다.
- 유저는 삭제되지 않습니다.
+
+ {L.members.cancelInviteExpires(account)}
+ {L.members.userNotDeleted}
-
+
{
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
@@ -132,7 +134,7 @@ const InviteMemberModal = ({
});
if (result === "success") onClose();
else if (result === "duplicate-account")
- setEmailError(DUPLICATE_ACCOUNT_ERROR);
+ setEmailError(L.members.duplicateAccount);
else setSendFailed(true);
} catch {
setSendFailed(true);
@@ -147,8 +149,8 @@ const InviteMemberModal = ({
{
@@ -161,8 +163,8 @@ const InviteMemberModal = ({
setUsername(normalizeUsernameInput(value))}
@@ -170,25 +172,25 @@ const InviteMemberModal = ({
/>
-
팀 / 권한
+
{L.members.teamRole}
{sets.map((set, index) => (
patchSet(set.id, { teamId })}
- ariaLabel={`세트 ${index + 1} 팀`}
+ ariaLabel={L.members.setTeamAria(index + 1)}
/>
patchSet(set.id, { role })}
- ariaLabel={`세트 ${index + 1} role`}
+ ariaLabel={L.members.setRoleAria(index + 1)}
/>
{/* First set is required and carries no remove button
@@ -219,9 +221,15 @@ const InviteMemberModal = ({
{showPreview && (
- 하위 팀 권한 미리보기
+
+ {L.members.subteamPreview}
+
[
row.indent ? `└ ${row.teamName}` : row.teamName,
row.role,
@@ -232,22 +240,21 @@ const InviteMemberModal = ({
)}
{sendFailed ? (
- {SEND_FAILED_MESSAGE}
+ {L.members.sendFailed}
) : (
- 초대 시 사용자에게 초대 코드가 발송되어 사용자가 24시간 내 rune을
- 연결하면{" "}
+ {L.members.invitePrefix}
{" "}
- 으로 전환됩니다. 미연결 시 코드가 만료되고 초대 코드
- 재전송으로 사용자를 다시 초대할 수 있습니다.
+ />
+ {L.members.inviteSuffix}
+ {L.members.inviteExpiry}
)}
-
+
/* table-fixed: the 팀/권한 columns split 50/50 regardless of content,
so the per-user tables all line up. */
[m.teamName, m.role])}
className="table-fixed"
/>
) : (
- 소속된 팀이 없습니다.
+ {L.members.noTeams}
);
@@ -71,7 +75,7 @@ const MemberDeleteModal = ({
if (failed) {
return (
- {DELETE_FAILED_MESSAGE}
+ {L.members.deleteFailed}
- {single.account} 계정을 삭제하며, 아래 팀에서 제거됩니다:
+ {L.members.deleteSingleIntro(single.account)}
{memberTeams(single.memberships)}
>
) : (
<>
-
- 다음 멤버의 계정을 삭제하며, 아래 팀에서 제거됩니다:
-
+ {L.members.deleteBulkIntro}
{targets.map((target) => (
{target.account}
@@ -106,7 +108,7 @@ const MemberDeleteModal = ({
>
)}
-
+
{
- if (user.sessionStatus === "online") {
- return `최근 접속 ${formatDate(user.lastAccessAt)}`;
+ if (user.sessionStatus === SESSION_STATUS.online) {
+ return L.members.lastAccessed(formatDate(user.lastAccessAt));
}
switch (user.invitationStatus) {
- case "invite_redeemed":
- return "초대 코드 사용됨 · 연결 대기 중";
- case "invite_pending":
- case "invite_expired":
- return `최근 초대 코드 발송 ${formatDateTime(user.lastInvitedAt)}`;
+ case INVITATION_STATUS.redeemed:
+ return L.members.redeemedAwaiting;
+ case INVITATION_STATUS.pending:
+ case INVITATION_STATUS.expired:
+ return L.members.lastInviteSentAt(formatDateTime(user.lastInvitedAt));
}
};
-/** One staged membership row: baseRole is the saved value. */
-type TMembershipDraft = {
- teamId: string;
- teamName: string;
- baseRole: string;
- role: string;
- checked: boolean;
-};
-
type TDrawerModal =
| "role-confirm"
| "remove"
@@ -75,16 +60,6 @@ type TDrawerModal =
| "cancel-invitation"
| null;
-/** Batch-endpoint failure reasons shown by team name (SC-13 — shared
- with the team-side codes; the drawer only ever sees these two). */
-const BATCH_REASON: Record = {
- TEAM_NOT_FOUND: "팀을 찾을 수 없습니다",
- NOT_TEAM_MEMBER: "팀 멤버가 아닙니다",
-};
-// Any other code (e.g. a transient INTERNAL) shows a generic retry message
-// rather than leaking the raw backend code into the failure modal.
-const BATCH_REASON_FALLBACK = "처리에 실패했습니다. 다시 시도해 주세요.";
-
interface MemberDetailDrawerProps {
user: TUserListItem;
onClose: () => void;
@@ -117,8 +92,8 @@ interface MemberDetailDrawerProps {
* the role-change confirm modal) and checkbox bulk removal (SC-14),
* invite-code actions, and member delete (SC-15). Mount with
* key={user.userId} so switching members resets the staged state.
- * [초대 취소] (D15) and [세션 비활성화] (D12) ship with correct enable
- * rules and confirm dialogs.
+ * The membership machine lives in useMembershipDrafts; this component
+ * composes it with the account-level actions and the confirm modals.
*/
const MemberDetailDrawer = ({
user,
@@ -132,109 +107,35 @@ const MemberDetailDrawer = ({
onCancelInvitation,
teams,
}: MemberDetailDrawerProps) => {
- const [memberships, setMemberships] = useState(() =>
- user.memberships.map((m) => ({
- teamId: m.teamId,
- teamName: m.teamName,
- baseRole: m.role,
- role: m.role,
- checked: false,
- })),
- );
+ const drafts = useMembershipDrafts({
+ user,
+ teams,
+ onUpdateRoles,
+ onRemoveMemberships,
+ onAddMembership,
+ });
const [openModal, setOpenModal] = useState(null);
const [resending, setResending] = useState(false);
- const [addOpen, setAddOpen] = useState(false);
- const [addTeamId, setAddTeamId] = useState("");
- const [addRole, setAddRole] = useState("");
- const [adding, setAdding] = useState(false);
- const [batchFailures, setBatchFailures] = useState<
- { account: string; reason: string }[] | null
- >(null);
const showNotice = useNoticeStore((state) => state.showNotice);
- const teamOptions = buildTeamOptions(teams);
-
- const changes = memberships.filter((m) => m.role !== m.baseRole);
- const selected = memberships.filter((m) => m.checked);
- const allChecked =
- memberships.length > 0 && memberships.every((m) => m.checked);
-
- /* Sub-team retention notice (SC-14 no.2): a selected team has a
- descendant team whose membership stays after this removal. */
- const remainingIds = memberships
- .filter((m) => !m.checked)
- .map((m) => m.teamId);
- const subteamNotice = selected.some((m) =>
- getTeamDescendantIds(teams, m.teamId).some((id) =>
- remainingIds.includes(id),
- ),
- );
-
- const patchMembership = (teamId: string, patch: Partial) =>
- setMemberships((prev) =>
- prev.map((m) => (m.teamId === teamId ? { ...m, ...patch } : m)),
- );
+ const closeModal = () => setOpenModal(null);
const handleResend = async () => {
setResending(true);
try {
await onResendCode();
- showNotice("초대 코드 재전송", "초대 코드를 재전송했습니다.", "info");
- } catch {
showNotice(
- "초대 코드 재전송",
- "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.",
- "error",
+ NOTICE_TEXT.resendInvitation.title,
+ NOTICE_TEXT.resendInvitation.success,
+ "info",
);
- } finally {
- setResending(false);
- }
- };
-
- /* Teams the user already belongs to stay out of the add picker.
- Depth indent stripped — the narrow drawer dropdown can't fit
- deep-tree indentation (it forces horizontal scrolling in the
- menu); teams list flush left in tree order and long names
- truncate with an ellipsis. */
- const joinedIds = new Set(memberships.map((m) => m.teamId));
- const addableTeams = teamOptions
- .filter((o) => !joinedIds.has(o.value))
- .map(({ value, label }) => ({ value, label }));
-
- const resetAdd = () => {
- setAddOpen(false);
- setAddTeamId("");
- setAddRole("");
- };
-
- const handleAdd = async () => {
- setAdding(true);
- try {
- await onAddMembership(addTeamId, addRole);
- const teamName =
- teamOptions.find((o) => o.value === addTeamId)?.label ?? addTeamId;
- setMemberships((prev) => [
- ...prev,
- {
- teamId: addTeamId,
- teamName,
- baseRole: addRole,
- role: addRole,
- checked: false,
- },
- ]);
- showNotice("팀 추가", "팀에 추가되었습니다.", "info");
- resetAdd();
- } catch (err) {
- const code = err instanceof Response ? await parseErrorCode(err) : "";
+ } catch {
showNotice(
- "팀 추가",
- code === "ALREADY_TEAM_MEMBER"
- ? "이미 소속된 팀입니다."
- : "팀 추가에 실패했습니다. 다시 시도해 주세요.",
+ NOTICE_TEXT.resendInvitation.title,
+ NOTICE_TEXT.resendInvitation.failure,
"error",
);
} finally {
- setAdding(false);
+ setResending(false);
}
};
@@ -284,7 +185,7 @@ const MemberDetailDrawer = ({
btnSize="sm"
btnColor="grayOutline"
className="w-fit"
- disabled={user.invitationStatus !== "invite_pending"}
+ disabled={user.invitationStatus !== INVITATION_STATUS.pending}
handleClick={() => setOpenModal("cancel-invitation")}
/>
@@ -293,147 +194,33 @@ const MemberDetailDrawer = ({
-
-
- 소속 팀 ({memberships.length})
- {selected.length > 0 && (
-
- {selected.length} selected
-
- )}
-
-
-
-
-
-
- setMemberships((prev) =>
- prev.map((m) => ({ ...m, checked })),
- )
- }
- ariaLabel="전체선택"
- />
-
- 팀
- 권한
-
-
- {memberships.length === 0 ? (
- /* No group-role membership — a single placeholder row keeps
- the table shape; the team/role cells read "—". */
-
-
- —
- —
-
- ) : (
- memberships.map((m) => (
-
- patchMembership(m.teamId, { checked })
- }
- onRoleChange={(role) => patchMembership(m.teamId, { role })}
- />
- ))
- )}
-
-
-
- {/* Action bar: 변경사항 초기화 · 변경사항 업데이트 · 제거하기 ·
- 팀 추가하기 (SC-13). */}
-
- {/* Drops every staged (not yet applied) role pick back to its
- saved value — checkboxes and committed roles stay. */}
-
- setMemberships((prev) =>
- prev.map((m) => ({ ...m, role: m.baseRole })),
- )
- }
- />
- setOpenModal("role-confirm")}
- />
- setOpenModal("remove")}
- />
- (addOpen ? resetAdd() : setAddOpen(true))}
- />
-
-
- {/* Team+role picker (SC-13 no.2) — opens just above the action
- bar via [팀 추가하기]; teams already joined are excluded. */}
- {addOpen && (
-
-
-
-
-
- )}
-
+
setOpenModal("role-confirm")}
+ onOpenRemove={() => setOpenModal("remove")}
+ addOpen={drafts.addOpen}
+ addTeamId={drafts.addTeamId}
+ addRole={drafts.addRole}
+ adding={drafts.adding}
+ addableTeams={drafts.addableTeams}
+ onAddTeamIdChange={drafts.setAddTeamId}
+ onAddRoleChange={drafts.setAddRole}
+ onToggleAddRow={drafts.toggleAddRow}
+ onAdd={drafts.handleAdd}
+ />
- 멤버 관리
+ {L.nav.users}
{/* Account-level actions: 세션 비활성화 · 멤버 삭제 (SC-15) —
@@ -448,7 +235,7 @@ const MemberDetailDrawer = ({
btnSize="sm"
btnColor="redOutline"
className="w-fit"
- disabled={user.sessionStatus !== "online"}
+ disabled={user.sessionStatus !== SESSION_STATUS.online}
handleClick={() => setOpenModal("deactivate")}
/>
({
+ subjectLabel={TABLE_HEADERS.team}
+ changes={drafts.changes.map((m) => ({
label: m.teamName,
from: m.baseRole,
to: m.role,
}))}
- onConfirm={async () => {
- const changedIds = changes.map((m) => m.teamId);
- const result = await onUpdateRoles(
- changes.map((m) => ({ teamId: m.teamId, role: m.role })),
- );
- const failedIds = new Set(result.failed.map((f) => f.id));
- setMemberships((prev) =>
- prev.map((m) =>
- changedIds.includes(m.teamId) && !failedIds.has(m.teamId)
- ? { ...m, baseRole: m.role }
- : m,
- ),
- );
- if (result.failed.length > 0) {
- setBatchFailures(
- result.failed.map((f) => ({
- account:
- memberships.find((m) => m.teamId === f.id)?.teamName ??
- f.id,
- reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK,
- })),
- );
- }
- }}
- onClose={() => setOpenModal(null)}
+ onConfirm={drafts.confirmRoleChanges}
+ onClose={closeModal}
/>
)}
{openModal === "remove" && (
({
+ targets={drafts.selected.map((m) => ({
account: user.account,
teamId: m.teamId,
teamName: m.teamName,
role: m.role,
}))}
- subteamNotice={subteamNotice}
- onConfirm={async () => {
- const removedIds = selected.map((m) => m.teamId);
- const result = await onRemoveMemberships(removedIds);
- const failedIds = new Set(result.failed.map((f) => f.id));
- setMemberships((prev) =>
- prev.filter(
- (m) =>
- !removedIds.includes(m.teamId) || failedIds.has(m.teamId),
- ),
- );
- if (result.failed.length === 0) {
- showNotice(
- MODAL_TITLES.removeMembership,
- "멤버십이 제거되었습니다.",
- "success",
- );
- } else {
- setBatchFailures(
- result.failed.map((f) => ({
- account:
- memberships.find((m) => m.teamId === f.id)?.teamName ??
- f.id,
- reason: BATCH_REASON[f.code] ?? BATCH_REASON_FALLBACK,
- })),
- );
- }
- }}
- onClose={() => setOpenModal(null)}
+ subteamNotice={drafts.subteamNotice}
+ onConfirm={drafts.confirmRemovals}
+ onClose={closeModal}
/>
)}
@@ -543,14 +281,14 @@ const MemberDetailDrawer = ({
targets={[
{
account: user.account,
- memberships: memberships.map((m) => ({
+ memberships: drafts.memberships.map((m) => ({
teamName: m.teamName,
role: m.baseRole,
})),
},
]}
onConfirm={onDeleteMember}
- onClose={() => setOpenModal(null)}
+ onClose={closeModal}
/>
)}
@@ -560,22 +298,26 @@ const MemberDetailDrawer = ({
onConfirm={async () => {
try {
await onDeactivateSession();
- setOpenModal(null);
- showNotice("세션 비활성화", "세션을 비활성화했습니다.", "info");
+ closeModal();
+ showNotice(
+ NOTICE_TEXT.deactivateSession.title,
+ NOTICE_TEXT.deactivateSession.success,
+ "info",
+ );
} catch (err) {
const code =
err instanceof Response ? await parseErrorCode(err) : "";
- setOpenModal(null);
+ closeModal();
showNotice(
- "세션 비활성화",
- code === "SESSION_NOT_ACTIVE"
- ? "이미 만료된 세션입니다."
- : "세션 비활성화에 실패했습니다. 다시 시도해 주세요.",
+ NOTICE_TEXT.deactivateSession.title,
+ code === ERROR_CODES.SESSION_NOT_ACTIVE
+ ? NOTICE_TEXT.deactivateSession.alreadyExpired
+ : NOTICE_TEXT.deactivateSession.failure,
"error",
);
}
}}
- onClose={() => setOpenModal(null)}
+ onClose={closeModal}
/>
)}
@@ -585,29 +327,33 @@ const MemberDetailDrawer = ({
onConfirm={async () => {
try {
await onCancelInvitation();
- setOpenModal(null);
- showNotice("초대 취소", "초대를 취소했습니다.", "info");
+ closeModal();
+ showNotice(
+ NOTICE_TEXT.cancelInvitation.title,
+ NOTICE_TEXT.cancelInvitation.success,
+ "info",
+ );
} catch (err) {
const code =
err instanceof Response ? await parseErrorCode(err) : "";
- setOpenModal(null);
+ closeModal();
showNotice(
- "초대 취소",
- code === "INVITATION_NOT_PENDING"
- ? "취소할 초대가 없습니다."
- : "초대 취소에 실패했습니다. 다시 시도해 주세요.",
+ NOTICE_TEXT.cancelInvitation.title,
+ code === ERROR_CODES.INVITATION_NOT_PENDING
+ ? NOTICE_TEXT.cancelInvitation.nothingToCancel
+ : NOTICE_TEXT.cancelInvitation.failure,
"error",
);
}
}}
- onClose={() => setOpenModal(null)}
+ onClose={closeModal}
/>
)}
- {batchFailures && (
+ {drafts.batchFailures && (
setBatchFailures(null)}
+ failures={drafts.batchFailures}
+ onClose={drafts.closeBatchFailures}
/>
)}
>
diff --git a/frontend/src/components/users/MembershipRemoveModal.tsx b/frontend/src/components/users/MembershipRemoveModal.tsx
index 91925ed..31015a3 100644
--- a/frontend/src/components/users/MembershipRemoveModal.tsx
+++ b/frontend/src/components/users/MembershipRemoveModal.tsx
@@ -4,10 +4,14 @@ import Button from "@/components/elements/Button";
import Notice from "@/components/elements/Notice";
import ModalLayout from "@/components/layout/ModalLayout";
import ModalTable from "@/components/users/ModalTable";
-import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
+import {
+ BTN_TEXT,
+ MODAL_TITLES,
+ TABLE_HEADERS,
+} from "@/constants/commonConstants";
+import { MODAL_STYLE_VAR } from "@/constants/styleConstants";
import type { TMembershipRemoveTarget } from "@/types/userTypes";
-
-const REMOVE_FAILED_MESSAGE = `멤버십 제거에 실패했습니다. 다시 시도해 주세요.`;
+import { L } from "@/locales";
interface MembershipRemoveModalProps {
/** SC-06 entry: selected members × current team · SC-13 entry:
@@ -53,7 +57,7 @@ const MembershipRemoveModal = ({
if (failed) {
return (
- {REMOVE_FAILED_MESSAGE}
+ {L.members.removeFailed}
-
다음 멤버십을 제거합니다:
+
{L.teams.removeIntro}
[
target.account,
target.teamName,
target.role,
])}
/>
- {subteamNotice && (
-
- 하위 팀 소속은 유지됩니다. 필요할 경우 개별 선택 후 제거하세요.
-
- )}
+ {subteamNotice && {L.teams.removeKeepSubteams} }
-
+
@@ -56,7 +57,10 @@ const MembershipRow = ({
>
{name}
{changed && (
-
+
CHANGED
)}
diff --git a/frontend/src/components/users/MembershipSection.tsx b/frontend/src/components/users/MembershipSection.tsx
new file mode 100644
index 0000000..8a26664
--- /dev/null
+++ b/frontend/src/components/users/MembershipSection.tsx
@@ -0,0 +1,216 @@
+import Button from "@/components/elements/Button";
+import Checkbox from "@/components/elements/Checkbox";
+import Dropdown from "@/components/elements/Dropdown";
+import Table from "@/components/table/Table";
+import TableCell from "@/components/table/TableCell";
+import TableHead from "@/components/table/TableHead";
+import TableHeaderCell from "@/components/table/TableHeaderCell";
+import TableRow from "@/components/table/TableRow";
+import MembershipRow from "@/components/users/MembershipRow";
+import type { TMembershipDraft } from "@/hooks/useMembershipDrafts";
+import {
+ ARIA_LABELS,
+ BTN_TEXT,
+ PLACEHOLDERS,
+ TABLE_HEADERS,
+} from "@/constants/commonConstants";
+import { ROLE_OPTIONS } from "@/constants/teamConstants";
+import type { TDropdownOption } from "@/types/commonTypes";
+import { L } from "@/locales";
+
+const styles = {
+ sectionHead: "flex items-center gap-2",
+ selectedCount: "text-accent-blue text-tag font-mono",
+ /* Action bar below the table: 변경사항 업데이트 · 제거하기 · 팀 추가하기. */
+ bulkRow: "pt-2 flex justify-end gap-2",
+ /* Team+role picker row opened by [팀 추가하기]. */
+ addRow:
+ "bg-muted-foreground/[2%] mb-2 flex items-center gap-2 rounded-md border p-2",
+};
+
+interface MembershipSectionProps {
+ memberships: TMembershipDraft[];
+ changesCount: number;
+ selectedCount: number;
+ allChecked: boolean;
+ onCheck: (teamId: string, checked: boolean) => void;
+ onCheckAll: (teamIds: string[], checked: boolean) => void;
+ onRoleChange: (teamId: string, role: string) => void;
+ onResetChanges: () => void;
+ onOpenRoleConfirm: () => void;
+ onOpenRemove: () => void;
+ /* [팀 추가하기] picker row (SC-13 no.2). */
+ addOpen: boolean;
+ addTeamId: string;
+ addRole: string;
+ adding: boolean;
+ addableTeams: TDropdownOption[];
+ onAddTeamIdChange: (teamId: string) => void;
+ onAddRoleChange: (role: string) => void;
+ onToggleAddRow: () => void;
+ onAdd: () => void;
+}
+
+/**
+ * MembershipSection is the 소속 팀 block of the member drawer (SC-13):
+ * the staged-edit membership table, the action bar, and the add-team
+ * picker row. Pure view — all state lives in useMembershipDrafts.
+ */
+const MembershipSection = ({
+ memberships,
+ changesCount,
+ selectedCount,
+ allChecked,
+ onCheck,
+ onCheckAll,
+ onRoleChange,
+ onResetChanges,
+ onOpenRoleConfirm,
+ onOpenRemove,
+ addOpen,
+ addTeamId,
+ addRole,
+ adding,
+ addableTeams,
+ onAddTeamIdChange,
+ onAddRoleChange,
+ onToggleAddRow,
+ onAdd,
+}: MembershipSectionProps) => {
+ return (
+
+
+ {L.members.teamsHeading(memberships.length)}
+ {selectedCount > 0 && (
+ {selectedCount} selected
+ )}
+
+
+
+
+
+
+ onCheckAll(
+ memberships.map((m) => m.teamId),
+ checked,
+ )
+ }
+ ariaLabel={ARIA_LABELS.selectAll}
+ />
+
+ {TABLE_HEADERS.team}
+
+ {TABLE_HEADERS.role}
+
+
+
+ {memberships.length === 0 ? (
+ /* No group-role membership — a single placeholder row keeps
+ the table shape; the team/role cells read "—". */
+
+
+ —
+ —
+
+ ) : (
+ memberships.map((m) => (
+ onCheck(m.teamId, checked)}
+ onRoleChange={(role) => onRoleChange(m.teamId, role)}
+ />
+ ))
+ )}
+
+
+
+ {/* Action bar: 변경사항 초기화 · 변경사항 업데이트 · 제거하기 ·
+ 팀 추가하기 (SC-13). */}
+
+ {/* Drops every staged (not yet applied) role pick back to its
+ saved value — checkboxes and committed roles stay. */}
+
+
+
+
+
+
+ {/* Team+role picker (SC-13 no.2) — opens just above the action
+ bar via [팀 추가하기]; teams already joined are excluded. */}
+ {addOpen && (
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default MembershipSection;
diff --git a/frontend/src/components/users/RoleChangeConfirmModal.tsx b/frontend/src/components/users/RoleChangeConfirmModal.tsx
index c724166..750dfe7 100644
--- a/frontend/src/components/users/RoleChangeConfirmModal.tsx
+++ b/frontend/src/components/users/RoleChangeConfirmModal.tsx
@@ -4,17 +4,20 @@ import Button from "@/components/elements/Button";
import Notice from "@/components/elements/Notice";
import ModalLayout from "@/components/layout/ModalLayout";
import ModalTable from "@/components/users/ModalTable";
-import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
+import {
+ BTN_TEXT,
+ MODAL_TITLES,
+ TABLE_HEADERS,
+} from "@/constants/commonConstants";
+import { MODAL_STYLE_VAR } from "@/constants/styleConstants";
import type { TRoleChange } from "@/types/userTypes";
-
-const UPDATE_FAILED_MESSAGE = "권한 변경에 실패했습니다. 다시 시도해 주세요.";
-const UPDATE_SUCCESS_MESSAGE = "권한이 변경되었습니다.";
+import { L } from "@/locales";
type TPhase = "confirm" | "success" | "failed";
interface RoleChangeConfirmModalProps {
changes: TRoleChange[];
- /** First-column header — "account" (SC-06 E) or "팀" (SC-13). */
+ /** First-column header — "account" (SC-06 E) or L.common.team (SC-13). */
subjectLabel: string;
/** Sends the staged changes; the result message shows inside the
modal (states E-1/E-2) — [닫기] alone remains after either. */
@@ -61,9 +64,9 @@ const RoleChangeConfirmModal = ({
return (
-
다음 멤버의 권한을 변경합니다:
+
{L.teams.roleChangeIntro}
[
change.label,
<>
@@ -72,13 +75,13 @@ const RoleChangeConfirmModal = ({
])}
/>
{phase === "success" && (
- {UPDATE_SUCCESS_MESSAGE}
+ {L.members.roleChanged}
)}
{phase === "failed" && (
- {UPDATE_FAILED_MESSAGE}
+ {L.members.roleChangeFailedRetry}
)}
-
+
-
- {account}의 세션을 비활성화하시겠습니까?
- 모든 MCP 세션이 종료됩니다.
+
+ {L.members.deactivateConfirm(account)}
+ {L.members.allMcpTerminated}
-
+
{
+ const [first, ...rest] = user.memberships;
+ return first
+ ? { summary: `${first.teamName} · ${first.role}`, extra: rest.length }
+ : { summary: "—", extra: 0 };
+};
+
+interface UserRowProps {
+ user: TUserListItem;
+ selected: boolean;
+ onSelect: (checked: boolean) => void;
+ onOpen: () => void;
+}
+
+/** UserRow is one SC-11 list row: checkbox, name, session chip, and the
+ "first team · role +n" membership summary. Row click opens the drawer. */
+const UserRow = ({ user, selected, onSelect, onOpen }: UserRowProps) => {
+ const { summary, extra } = membershipSummary(user);
+ return (
+
+ {/* Checkbox clicks must not open the drawer (SC-11 no.8) */}
+
+ e.stopPropagation()}>
+
+
+
+
+ {user.username}
+
+
+
+
+
+ {summary}
+ {extra > 0 && (
+ `${m.teamName} · ${m.role}`)
+ .join(", ")}
+ >
+ +{extra}
+
+ )}
+
+
+ );
+};
+
+export default UserRow;
diff --git a/frontend/src/components/users/UsersToolbar.tsx b/frontend/src/components/users/UsersToolbar.tsx
new file mode 100644
index 0000000..b2930ed
--- /dev/null
+++ b/frontend/src/components/users/UsersToolbar.tsx
@@ -0,0 +1,129 @@
+import Button from "@/components/elements/Button";
+import Dropdown from "@/components/elements/Dropdown";
+import SearchInput from "@/components/elements/SearchInput";
+import { ARIA_LABELS, BTN_TEXT } from "@/constants/commonConstants";
+import type { TDropdownOption } from "@/types/commonTypes";
+import { L } from "@/locales";
+
+interface UsersToolbarProps {
+ search: string;
+ sort: string;
+ statusFilter: string;
+ groupFilter: string;
+ sortOptions: TDropdownOption[];
+ statusOptions: TDropdownOption[];
+ groupOptions: TDropdownOption[];
+ /** Setters arrive page-reset-wrapped from the page (stale page = wrong
+ slice, and carried-over checks would mislead bulk actions). */
+ onSearchChange: (value: string) => void;
+ onSortChange: (value: string) => void;
+ onStatusChange: (value: string) => void;
+ onGroupChange: (value: string) => void;
+ selectedCount: number;
+ onResend: () => void;
+ onOpenBulkDelete: () => void;
+ onOpenInvite: () => void;
+}
+
+/** UsersToolbar is the SC-11 header strip (no.2–6): name search, the
+ sort/status/team dropdowns, and the bulk actions. Pure view. */
+const UsersToolbar = ({
+ search,
+ sort,
+ statusFilter,
+ groupFilter,
+ sortOptions,
+ statusOptions,
+ groupOptions,
+ onSearchChange,
+ onSortChange,
+ onStatusChange,
+ onGroupChange,
+ selectedCount,
+ onResend,
+ onOpenBulkDelete,
+ onOpenInvite,
+}: UsersToolbarProps) => {
+ return (
+
+
+
+
+ {/* filter/order dropdown */}
+
+
+ {L.common.sortBy}
+
+
+
+
+ {L.common.memberStatus}
+
+
+
+
+ {L.common.team}
+
+
+
+
+
+ {/* Actions — second row, left-aligned (SC-11 no.4–6) */}
+
+
+
+
+
+
+
+ );
+};
+
+export default UsersToolbar;
diff --git a/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx b/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx
index 67d5def..02b0120 100644
--- a/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx
+++ b/frontend/src/components/users/__tests__/MemberDetailDrawer.test.tsx
@@ -3,11 +3,11 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import MemberDetailDrawer from "@/components/users/MemberDetailDrawer";
+import { useNoticeStore } from "@/state/store/noticeStore";
+import { formatDate, formatDateTime } from "@/utils/formatDate";
import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
import type { TBatchResult, TTeamTree } from "@/types/teamTypes";
import type { TUserListItem } from "@/types/userTypes";
-import { formatDate, formatDateTime } from "@/utils/formatDate";
-import { useNoticeStore } from "@/stores/noticeStore";
/** Minimal team fixture — matches the user's one membership plus a
second, unjoined team for the add picker. */
@@ -71,9 +71,7 @@ describe("MemberDetailDrawer", () => {
render( );
/* Header shows the display name as the title and the account (the
identifier) right below it. */
- expect(
- screen.getByRole("heading", { name: "김철수" }),
- ).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "김철수" })).toBeInTheDocument();
expect(screen.getByText("k@corp.com")).toBeInTheDocument();
expect(screen.getByText("백엔드")).toBeInTheDocument();
});
@@ -105,6 +103,43 @@ describe("MemberDetailDrawer", () => {
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
+ it("renders refetched memberships from props and keeps staged edits on top", async () => {
+ const user = userEvent.setup();
+ const props = baseProps();
+ const { rerender } = render( );
+
+ /* Stage a role pick before the fresher server payload lands. */
+ await user.click(screen.getByRole("button", { name: "백엔드 role" }));
+ await user.click(screen.getByRole("option", { name: "write" }));
+
+ /* The detail query (or a post-mutation refetch) resolves with an
+ extra membership — the drawer must render it without a remount. */
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText("소속 팀 (2)")).toBeInTheDocument();
+ expect(screen.getByText("디자인")).toBeInTheDocument();
+
+ /* The staged (unapplied) pick survives the refetch: the 백엔드 row
+ still shows write and the update button stays armed. */
+ expect(
+ screen.getByRole("button", { name: "백엔드 role" }),
+ ).toHaveTextContent("write");
+ expect(
+ screen.getByRole("button", { name: BTN_TEXT.updateChanges }),
+ ).toBeEnabled();
+ });
+
it("stages a role change, confirms, and calls onUpdateRoles with {updates}", async () => {
const user = userEvent.setup();
const props = baseProps();
@@ -133,17 +168,17 @@ describe("MemberDetailDrawer", () => {
await user.click(screen.getByRole("button", { name: "백엔드 role" }));
await user.click(screen.getByRole("option", { name: "write" }));
- expect(screen.getByRole("button", { name: "백엔드 role" })).toHaveTextContent(
- "write",
- );
+ expect(
+ screen.getByRole("button", { name: "백엔드 role" }),
+ ).toHaveTextContent("write");
await user.click(reset);
/* The staged pick is gone: the dropdown shows the saved role again
and both staged-change buttons drop back to disabled. Reset is
purely client-side staging — no batch call fires. */
- expect(screen.getByRole("button", { name: "백엔드 role" })).toHaveTextContent(
- "edit",
- );
+ expect(
+ screen.getByRole("button", { name: "백엔드 role" }),
+ ).toHaveTextContent("edit");
expect(reset).toBeDisabled();
expect(
screen.getByRole("button", { name: BTN_TEXT.updateChanges }),
diff --git a/frontend/src/components/drawer/__tests__/MembershipRow.test.tsx b/frontend/src/components/users/__tests__/MembershipRow.test.tsx
similarity index 96%
rename from frontend/src/components/drawer/__tests__/MembershipRow.test.tsx
rename to frontend/src/components/users/__tests__/MembershipRow.test.tsx
index c2165c3..f4a8d8e 100644
--- a/frontend/src/components/drawer/__tests__/MembershipRow.test.tsx
+++ b/frontend/src/components/users/__tests__/MembershipRow.test.tsx
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
-import MembershipRow from "@/components/drawer/MembershipRow";
+import MembershipRow from "@/components/users/MembershipRow";
const ROLE_OPTIONS = [
{ value: "edit", label: "edit" },
diff --git a/frontend/src/components/workspace/WorkspaceModal.tsx b/frontend/src/components/workspace/WorkspaceModal.tsx
index 13c47f9..bae7cc1 100644
--- a/frontend/src/components/workspace/WorkspaceModal.tsx
+++ b/frontend/src/components/workspace/WorkspaceModal.tsx
@@ -16,17 +16,20 @@ import {
isTransitionalStatus,
useWorkspaceQuery,
} from "@/hooks/queries/useWorkspaceQuery";
+import { useWorkspaceStore } from "@/state/store/workspaceStore";
+import { WORKSPACE_STATUS } from "@/constants/apiConstants";
import {
BTN_TEXT,
MODAL_TITLES,
PATH_LIST,
WORKSPACE_MAX_MEMORIES,
} from "@/constants/commonConstants";
-import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { MODAL_STYLE_VAR } from "@/constants/styleConstants";
+import { L } from "@/locales";
const FAIL_COPY = {
- stop: "워크스페이스 중지에 실패했습니다. 다시 시도해 주세요.",
- restart: "워크스페이스 재실행에 실패했습니다. 다시 시도해 주세요.",
+ stop: L.workspace.stopFailed,
+ restart: L.workspace.restartFailed,
} as const;
const styles = {
@@ -71,7 +74,7 @@ const WorkspaceModal = () => {
// connect endpoint the empty-state create uses.
const reconnectMutation = useCreateWorkspaceMutation();
- const status = workspace?.status ?? "error";
+ const status = workspace?.status ?? WORKSPACE_STATUS.error;
/* Transitional phases (+ any request in flight) lock the actions. */
const busy = workspace ? isTransitionalStatus(status) : false;
@@ -91,7 +94,7 @@ const WorkspaceModal = () => {
return (
- 워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요.
+ {L.workspace.recreateFailed}
{closeButton}
@@ -104,7 +107,7 @@ const WorkspaceModal = () => {
return (
- 워크스페이스 삭제에 실패했습니다. 다시 시도해 주세요.
+ {L.workspace.deleteFailed}
{closeButton}
@@ -112,12 +115,12 @@ const WorkspaceModal = () => {
}
return (
-
- 워크스페이스를 삭제하시겠습니까?
+
+ {L.workspace.deleteConfirm}
- 삭제 후에는 되돌릴 수 없습니다.
+ {L.workspace.deleteIrreversible}
-
+
{
return (
{tearingDown ? (
-
- 기존 워크스페이스를 삭제하는 중입니다…
+
+ {L.workspace.tearingDown1}
- 삭제가 완료되면 워크스페이스 생성을 시작합니다.
+ {L.workspace.tearingDown2}
) : (
-
- 콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다.
+
+ {L.workspace.orphaned1}
- 기존에 저장된 데이터는 이전 보안 키로 암호화되어 복구할 수 없습니다.
+ {L.workspace.orphaned2}
- 삭제 후 재생성하면 빈 워크스페이스로 다시 시작합니다.
+ {L.workspace.orphaned3}
)}
-
+
{
if (workspace?.reconnectRequired) {
return (
-
- 워크스페이스 연결이 만료되었습니다.
+
+ {L.workspace.reconnectExpired}
- 재연결하여 데이터 플레인을 다시 활성화해 주세요.
+ {L.workspace.reconnectPrompt}
{reconnectMutation.isError && (
- 재연결에 실패했습니다. 다시 시도해 주세요.
+ {L.workspace.reconnectFailed}
)}
-
+
{
if (isError && !workspace) {
return (
-
- 워크스페이스 정보를 불러올 수 없습니다.
+
+ {L.workspace.loadFailed}
- 잠시 후 다시 시도해 주세요.
+ {L.common.tryAgainLater}
{closeButton}
@@ -256,11 +259,11 @@ const WorkspaceModal = () => {
return (
-
+
{/* Lifecycle actions sit at the content's top-right as quiet
TextButtons — the info fields carry the primary reading weight. */}
- {status === "stopped" ? (
+ {status === WORKSPACE_STATUS.stopped ? (
{
- 플랜
+ {L.workspace.plan}
Free
- 상태
+ {L.workspace.statusLabel}
-
저장된 기억 개수
+
{L.workspace.storedMemories}
{workspace?.rowCount != null
? memoryUsage(workspace.rowCount)
diff --git a/frontend/src/components/workspace/__tests__/WorkspaceModal.test.tsx b/frontend/src/components/workspace/__tests__/WorkspaceModal.test.tsx
index cf084f0..ce4f8e5 100644
--- a/frontend/src/components/workspace/__tests__/WorkspaceModal.test.tsx
+++ b/frontend/src/components/workspace/__tests__/WorkspaceModal.test.tsx
@@ -2,9 +2,9 @@ import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import WorkspaceModal from "@/components/workspace/WorkspaceModal";
+import { useWorkspaceStore } from "@/state/store/workspaceStore";
import { BTN_TEXT, PATH_LIST } from "@/constants/commonConstants";
-import type { TWorkspaceStatus, TWorkspace } from "@/types/commonTypes";
-import { useWorkspaceStore } from "@/stores/workspaceStore";
+import type { TWorkspace, TWorkspaceStatus } from "@/types/workspaceTypes";
/* The modal's data + mutations are mocked so each render variant (detail,
busy, D-1/D-1 실패, D-2, D-3) can be driven directly. */
@@ -141,7 +141,9 @@ describe("WorkspaceModal", () => {
queryState = { data: { ...RUNNING, orphaned: true }, isError: false };
render( );
expect(
- screen.getByText(/콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다/),
+ screen.getByText(
+ /콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다/,
+ ),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: BTN_TEXT.recreate }),
@@ -197,7 +199,9 @@ describe("WorkspaceModal", () => {
recreateState = { isPending: false, isError: true };
render( );
expect(
- screen.getByText("워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요."),
+ screen.getByText(
+ "워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요.",
+ ),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: BTN_TEXT.recreate }),
diff --git a/frontend/src/constants/apiConstants.ts b/frontend/src/constants/apiConstants.ts
new file mode 100644
index 0000000..e60b0ad
--- /dev/null
+++ b/frontend/src/constants/apiConstants.ts
@@ -0,0 +1,67 @@
+/**
+ * Wire-contract vocabulary shared with the console API — the single source
+ * for status/role/error-code string values. Components must reference these
+ * (e.g. `INVITATION_STATUS.pending`) instead of typing the raw literal, so a
+ * backend value rename is a one-line change here and every typo is a compile
+ * error. The matching union types are derived from these objects in types/
+ * (e.g. TInvitationStatus), keeping constant and type in lockstep.
+ */
+
+/** Invitation-code lifecycle status on the wire (common contract). */
+export const INVITATION_STATUS = {
+ pending: "invite_pending",
+ expired: "invite_expired",
+ redeemed: "invite_redeemed",
+} as const;
+
+/** Session-token liveness on the wire (common contract). */
+export const SESSION_STATUS = {
+ online: "online",
+ offline: "offline",
+} as const;
+
+/** rune workspace lifecycle phase (console API `phase`). */
+export const WORKSPACE_STATUS = {
+ provisioning: "provisioning",
+ running: "running",
+ stopping: "stopping",
+ stopped: "stopped",
+ starting: "starting",
+ deleting: "deleting",
+ error: "error",
+} as const;
+
+/** Lifecycle reported by the privileged rune-console update agent. */
+export const SYSTEM_UPDATE_STATE = {
+ idle: "idle",
+ queued: "queued",
+ running: "running",
+ failed: "failed",
+ succeeded: "succeeded",
+} as const;
+
+/** Grantable member role (Admin is console-account only — API §0). */
+export const TEAM_MEMBER_ROLE = {
+ edit: "edit",
+ write: "write",
+ read: "read",
+} as const;
+
+/**
+ * Backend error codes surfaced through the shared error envelope
+ * (parseErrorCode). Keys mirror the wire value verbatim so call sites read
+ * the same as the API design doc.
+ */
+export const ERROR_CODES = {
+ ALREADY_TEAM_MEMBER: "ALREADY_TEAM_MEMBER",
+ CANNOT_INVITE_ADMIN: "CANNOT_INVITE_ADMIN",
+ INVITATION_NOT_PENDING: "INVITATION_NOT_PENDING",
+ MAIL_UPSTREAM_ERROR: "MAIL_UPSTREAM_ERROR",
+ NOT_TEAM_MEMBER: "NOT_TEAM_MEMBER",
+ SESSION_NOT_ACTIVE: "SESSION_NOT_ACTIVE",
+ TEAM_HAS_CHILDREN: "TEAM_HAS_CHILDREN",
+ TEAM_NAME_DUPLICATE: "TEAM_NAME_DUPLICATE",
+ TEAM_NAME_INVALID: "TEAM_NAME_INVALID",
+ TEAM_NOT_FOUND: "TEAM_NOT_FOUND",
+ USER_NOT_FOUND: "USER_NOT_FOUND",
+} as const;
diff --git a/frontend/src/constants/commonConstants.ts b/frontend/src/constants/commonConstants.ts
index d5bc00f..9cd9ca2 100644
--- a/frontend/src/constants/commonConstants.ts
+++ b/frontend/src/constants/commonConstants.ts
@@ -1,3 +1,5 @@
+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. */
@@ -7,56 +9,157 @@ export const BRAND_WORDMARK = "RUNE CONSOLE";
* workspace; the SC-02 modal renders usage as rowCount / max (percent). */
export const WORKSPACE_MAX_MEMORIES = 1000;
+/** DEFAULT_PAGE_SIZE is the fixed rows-per-page for every list table
+ * (users, sessions, team members) — caps the table height inside one
+ * screen and goes out as the ?size= query param on the list endpoints. */
+export const DEFAULT_PAGE_SIZE = 10;
+
/** BTN_TEXT is the single source of truth for visible action-button labels
* (Button `btnText` / TextButton) across the console screens, so a wording
* change lands in one place. Icon-button aria-labels are intentionally out of
* 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 = {
+ 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
@@ -65,24 +168,46 @@ export const BTN_TEXT = {
* strings. */
export const MODAL_TITLES = {
// Workspace
- workspaceManage: "워크스페이스 관리",
- workspaceDelete: "워크스페이스 삭제",
- workspaceOrphaned: "워크스페이스 재생성 필요",
- workspaceReconnect: "워크스페이스 재연결 필요",
+ workspaceManage: PAGE_TITLES.workspace,
+ 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 = {
@@ -95,12 +220,122 @@ 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: "팀 관리", url: PATH_LIST.teams },
- { title: "멤버 관리", url: PATH_LIST.users },
- { title: "세션 기록", 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 = {
+ 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. */
+ get roleAlt() {
+ return L.teams.roleHeader;
+ },
+ get roleChange() {
+ return L.modal.roleChange;
+ },
+ get joinedAt() {
+ return L.teams.joinedAt;
+ },
+ account: "account",
+ 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 = {
+ get emailAccount() {
+ return L.teams.emailLabel;
+ },
+ get username() {
+ return L.teams.usernameLabel;
+ },
+} as const;
+
+export const PLACEHOLDERS = {
+ get selectTeam() {
+ return L.teams.selectTeam;
+ },
+ get selectRole() {
+ return L.teams.selectRole;
+ },
+ /** Team picker when every team is already joined (SC-13 add row). */
+ get noAddableTeam() {
+ return L.members.noTeamsToAdd;
+ },
+ emailExample: "user@corp.com",
+ 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 = {
+ 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 = {
+ get refreshRetry() {
+ return L.common.refreshRetry;
+ },
+} as const;
+
export const QUERY_KEYS = {
teamsTree: "teamsTree",
users: "users",
diff --git a/frontend/src/constants/errorConstants.ts b/frontend/src/constants/errorConstants.ts
new file mode 100644
index 0000000..c1ca97b
--- /dev/null
+++ b/frontend/src/constants/errorConstants.ts
@@ -0,0 +1,59 @@
+import { ERROR_CODES } from "@/constants/apiConstants";
+import { L } from "@/locales";
+
+/**
+ * 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.
+ */
+
+/** Team CRUD failures (SC-06/07 — create · rename · delete). */
+export const TEAM_REASON: Record = {
+ 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 = {
+ 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;
+ },
+};
+
+/** 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 = {
+ 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
new file mode 100644
index 0000000..596b759
--- /dev/null
+++ b/frontend/src/constants/noticeConstants.ts
@@ -0,0 +1,113 @@
+import { MODAL_TITLES } from "@/constants/commonConstants";
+import { L } from "@/locales";
+
+/**
+ * showNotice copy grouped per flow — {title, success, failure, ...} so a
+ * flow's wording lives in one place instead of inline at each call site.
+ * Titles reuse MODAL_TITLES where the notice reports the outcome of that
+ * modal's action; flows without a matching modal title keep their own.
+ * Keys beyond success/failure are code-specific bodies (e.g. alreadyMember
+ * for ALREADY_TEAM_MEMBER) picked by the call site's error handling.
+ */
+export const NOTICE_TEXT = {
+ resendInvitation: {
+ 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. */
+ get failedReason() {
+ return L.members.resendFailedShort;
+ },
+ },
+ addMembership: {
+ 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,
+ get success() {
+ return L.teams.membershipsRemoved;
+ },
+ },
+ deactivateSession: {
+ title: MODAL_TITLES.deactivateSession,
+ get success() {
+ return L.members.sessionDeactivated;
+ },
+ get alreadyExpired() {
+ return L.members.sessionAlreadyExpired;
+ },
+ get failure() {
+ return L.members.deactivateFailed;
+ },
+ },
+ cancelInvitation: {
+ title: MODAL_TITLES.cancelInvitation,
+ get success() {
+ return L.members.invitationCanceled;
+ },
+ get nothingToCancel() {
+ return L.members.noInvitationToCancel;
+ },
+ get failure() {
+ return L.members.cancelInvitationFailed;
+ },
+ },
+ createTeam: {
+ get title() {
+ return L.teams.createTeamTitle;
+ },
+ get success() {
+ return L.teams.teamCreated;
+ },
+ },
+ renameTeam: {
+ title: MODAL_TITLES.renameTeam,
+ get success() {
+ return L.teams.teamRenamed;
+ },
+ },
+ deleteTeam: {
+ get title() {
+ return L.teams.deleteTeamTitle;
+ },
+ get success() {
+ return L.teams.teamDeleted;
+ },
+ },
+ addTeamMember: {
+ get title() {
+ return L.teams.addMemberTitle;
+ },
+ get success() {
+ return L.teams.memberAdded;
+ },
+ },
+ deleteMember: {
+ 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 fad01a1..9feb9f5 100644
--- a/frontend/src/constants/styleConstants.ts
+++ b/frontend/src/constants/styleConstants.ts
@@ -3,6 +3,13 @@
* Visual values are translated from UIKIT modules/rune-ui-buttons and
* modules/rune-admin-kit CSS — UIKIT is the design source of truth.
*/
+import type { TMemberStatus } from "@/types/commonTypes";
+import type { TInvitationStatus } from "@/types/teamTypes";
+import type { TWorkspaceStatus } from "@/types/workspaceTypes";
+import { L } from "@/locales";
+
+/** Status → chip/badge presentation (label + text color). */
+type TStatusStyle = { label: string; color: string };
/* Form controls embed w-full: the parent container constrains width.
Metrics are UIKIT values normalized to even px (project rule). */
@@ -79,25 +86,58 @@ export const BADGE_TONE_VAR = {
neutral: "bg-muted-foreground/12 text-muted-foreground",
} as const;
+/* Shared modal building blocks — the ModalLayout children every confirm
+ modal composes. One source so the copies can't drift (the body gap had
+ already split into gap-4 vs gap-5 before this was centralized). */
+export const MODAL_STYLE_VAR = {
+ /* Centered single-line message (alert/failure bodies). */
+ message: "text-center text-base",
+ /* Vertical form/content stack. */
+ body: "flex w-full flex-col gap-4",
+ /* Button row — one spacing for every confirm modal (the team/workspace
+ modals used to sit at gap-2 while the users flows used gap-4; unified
+ on gap-4, 2026-08-03). */
+ footer: "flex w-full items-center gap-4",
+} as const;
+
+/* 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;
+ 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;
+ 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;
+ 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
new file mode 100644
index 0000000..f8a4782
--- /dev/null
+++ b/frontend/src/constants/teamConstants.ts
@@ -0,0 +1,12 @@
+import { TEAM_MEMBER_ROLE } from "@/constants/apiConstants";
+import type { TDropdownOption } from "@/types/commonTypes";
+
+/** Team name rule: digits, Hangul, Latin letters, and - _ only. */
+export const TEAM_NAME_PATTERN = /^[0-9A-Za-z가-힣_-]+$/;
+
+/** Grantable member roles (Admin is console-account only — API §0). */
+export const ROLE_OPTIONS: TDropdownOption[] = [
+ { value: TEAM_MEMBER_ROLE.edit, label: TEAM_MEMBER_ROLE.edit },
+ { value: TEAM_MEMBER_ROLE.write, label: TEAM_MEMBER_ROLE.write },
+ { value: TEAM_MEMBER_ROLE.read, label: TEAM_MEMBER_ROLE.read },
+];
diff --git a/frontend/src/components/users/memberStatusMap.ts b/frontend/src/constants/userConstants.ts
similarity index 71%
rename from frontend/src/components/users/memberStatusMap.ts
rename to frontend/src/constants/userConstants.ts
index 2b384f0..93475b1 100644
--- a/frontend/src/components/users/memberStatusMap.ts
+++ b/frontend/src/constants/userConstants.ts
@@ -1,9 +1,10 @@
+import { SESSION_STATUS } from "@/constants/apiConstants";
import type { TMemberStatus } from "@/types/commonTypes";
import type { TSessionStatus } from "@/types/teamTypes";
/** API session status → MemberStatus chip state. Identity today, but kept as a
seam so the chip vocabulary can diverge from the wire later. */
export const CHIP_STATUS: Record = {
- online: "online",
- offline: "offline",
+ [SESSION_STATUS.online]: "online",
+ [SESSION_STATUS.offline]: "offline",
};
diff --git a/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts
new file mode 100644
index 0000000..ce48c74
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useBatchFailureModal.test.ts
@@ -0,0 +1,55 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import {
+ toBatchFailureRows,
+ useBatchFailureModal,
+} from "@/hooks/useBatchFailureModal";
+import { L } from "@/locales";
+
+describe("useBatchFailureModal", () => {
+ it("starts closed and opens with the given rows", () => {
+ const { result } = renderHook(() => useBatchFailureModal());
+ expect(result.current.batchFailures).toBeNull();
+ act(() =>
+ result.current.showBatchFailures([{ account: "a", reason: "r" }]),
+ );
+ expect(result.current.batchFailures).toEqual([
+ { account: "a", reason: "r" },
+ ]);
+ act(() => result.current.closeBatchFailures());
+ expect(result.current.batchFailures).toBeNull();
+ });
+});
+
+describe("toBatchFailureRows", () => {
+ const failed = [
+ { id: "u1", code: "USER_NOT_FOUND", message: "x" },
+ { id: "u2", code: "INTERNAL", message: "y" },
+ ];
+
+ it("maps known codes through BATCH_REASON and labels via labelOf", () => {
+ const rows = toBatchFailureRows(
+ failed,
+ (id) => `acct-${id}`,
+ () => L.common.processFailed,
+ );
+ expect(rows[0]).toEqual({
+ account: "acct-u1",
+ reason: "사용자를 찾을 수 없습니다",
+ });
+ expect(rows[1]).toEqual({
+ account: "acct-u2",
+ reason: L.common.processFailed,
+ });
+ });
+
+ it("supports the raw-code fallback used by removal/delete flows", () => {
+ const rows = toBatchFailureRows(
+ failed,
+ (id) => id,
+ (code) => code,
+ );
+ expect(rows[1].reason).toBe("INTERNAL");
+ });
+});
diff --git a/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts b/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts
new file mode 100644
index 0000000..2b267e5
--- /dev/null
+++ b/frontend/src/hooks/__tests__/usePageScopedSelection.test.ts
@@ -0,0 +1,44 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { usePageScopedSelection } from "@/hooks/usePageScopedSelection";
+
+describe("usePageScopedSelection", () => {
+ it("toggles single ids on and off", () => {
+ const { result } = renderHook(() => usePageScopedSelection());
+ act(() => result.current.toggleOne("a", true));
+ act(() => result.current.toggleOne("b", true));
+ expect(result.current.selectedIds).toEqual(new Set(["a", "b"]));
+ act(() => result.current.toggleOne("a", false));
+ expect(result.current.selectedIds).toEqual(new Set(["b"]));
+ });
+
+ it("toggleAll adds and removes only the given ids", () => {
+ const { result } = renderHook(() => usePageScopedSelection());
+ act(() => result.current.toggleOne("keep", true));
+ act(() => result.current.toggleAll(["a", "b"], true));
+ expect(result.current.selectedIds).toEqual(new Set(["keep", "a", "b"]));
+ act(() => result.current.toggleAll(["a", "b"], false));
+ expect(result.current.selectedIds).toEqual(new Set(["keep"]));
+ });
+
+ it("clearSelection empties the set", () => {
+ const { result } = renderHook(() => usePageScopedSelection());
+ act(() => result.current.toggleAll(["a", "b"], true));
+ act(() => result.current.clearSelection());
+ expect(result.current.selectedIds.size).toBe(0);
+ });
+
+ it("setSelectedIds supports batch-result reconciliation", () => {
+ const { result } = renderHook(() => usePageScopedSelection());
+ act(() => result.current.toggleAll(["ok", "failed"], true));
+ act(() =>
+ result.current.setSelectedIds((prev) => {
+ const next = new Set(prev);
+ next.delete("ok");
+ return next;
+ }),
+ );
+ expect(result.current.selectedIds).toEqual(new Set(["failed"]));
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useServerPagination.test.ts b/frontend/src/hooks/__tests__/useServerPagination.test.ts
new file mode 100644
index 0000000..29edb2a
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useServerPagination.test.ts
@@ -0,0 +1,53 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { useServerPagination } from "@/hooks/useServerPagination";
+
+describe("useServerPagination", () => {
+ it("starts on page 1 with one page until a total arrives", () => {
+ const { result } = renderHook(() => useServerPagination(10));
+ expect(result.current.page).toBe(1);
+ expect(result.current.totalPages).toBe(1);
+ });
+
+ it("derives totalPages from the reported total", () => {
+ const { result } = renderHook(() => useServerPagination(10));
+ act(() => result.current.syncTotal(35));
+ expect(result.current.totalPages).toBe(4);
+ expect(result.current.page).toBe(1);
+ });
+
+ it("clamps the request page when the range shrinks", () => {
+ const { result } = renderHook(() => useServerPagination(10));
+ act(() => result.current.syncTotal(50));
+ act(() => result.current.setPage(5));
+ expect(result.current.page).toBe(5);
+ /* A sort/filter change or deletion shrinks the result set. */
+ act(() => result.current.syncTotal(21));
+ expect(result.current.totalPages).toBe(3);
+ expect(result.current.page).toBe(3);
+ });
+
+ it("never exposes a page beyond totalPages even before the correction", () => {
+ const { result } = renderHook(() => useServerPagination(10));
+ act(() => result.current.setPage(9));
+ /* totalPages still 1 — the returned page must stay in range so the
+ query never asks for an out-of-range slice. */
+ expect(result.current.page).toBe(1);
+ });
+
+ it("resetPage returns to page 1", () => {
+ const { result } = renderHook(() => useServerPagination(10));
+ act(() => result.current.syncTotal(50));
+ act(() => result.current.setPage(4));
+ act(() => result.current.resetPage());
+ expect(result.current.page).toBe(1);
+ });
+
+ it("treats an empty result as a single page", () => {
+ const { result } = renderHook(() => useServerPagination(10));
+ act(() => result.current.syncTotal(0));
+ expect(result.current.totalPages).toBe(1);
+ expect(result.current.page).toBe(1);
+ });
+});
diff --git a/frontend/src/hooks/mutations/useUpdateMutation.ts b/frontend/src/hooks/mutations/useUpdateMutation.ts
index 25e8ce3..d6a72ed 100644
--- a/frontend/src/hooks/mutations/useUpdateMutation.ts
+++ b/frontend/src/hooks/mutations/useUpdateMutation.ts
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { postSystemUpdate } from "@/api/updateAPIs";
+import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants";
import { QUERY_KEYS } from "@/constants/commonConstants";
import type { TSystemUpdateStatus } from "@/types/updateTypes";
@@ -21,7 +22,7 @@ export const useUpdateMutation = () => {
? {
...current,
targetVersion: version,
- state: "queued",
+ state: SYSTEM_UPDATE_STATE.queued,
}
: current,
);
diff --git a/frontend/src/hooks/queries/useUpdateQuery.ts b/frontend/src/hooks/queries/useUpdateQuery.ts
index 0beb0b6..3fcb826 100644
--- a/frontend/src/hooks/queries/useUpdateQuery.ts
+++ b/frontend/src/hooks/queries/useUpdateQuery.ts
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { getSystemUpdate } from "@/api/updateAPIs";
+import { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants";
import { QUERY_KEYS } from "@/constants/commonConstants";
import type { TSystemUpdateStatus } from "@/types/updateTypes";
@@ -8,7 +9,7 @@ const ACTIVE_POLL_MS = 2000;
const IDLE_POLL_MS = 60 * 60 * 1000;
export const isSystemUpdateActive = (state: TSystemUpdateStatus["state"]) =>
- state === "queued" || state === "running";
+ state === SYSTEM_UPDATE_STATE.queued || state === SYSTEM_UPDATE_STATE.running;
/**
* Checks for a release without disturbing the app when GitHub or the local
diff --git a/frontend/src/hooks/queries/useWorkspaceQuery.ts b/frontend/src/hooks/queries/useWorkspaceQuery.ts
index 01506ab..a275a22 100644
--- a/frontend/src/hooks/queries/useWorkspaceQuery.ts
+++ b/frontend/src/hooks/queries/useWorkspaceQuery.ts
@@ -1,22 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { getWorkspace } from "@/api/workspaceAPIs";
+import { WORKSPACE_STATUS } from "@/constants/apiConstants";
import { QUERY_KEYS } from "@/constants/commonConstants";
import type {
- TWorkspaceStatus,
TWorkspace,
+ TWorkspaceStatus,
TWorkspaceWire,
-} from "@/types/commonTypes";
+} from "@/types/workspaceTypes";
/** How often to re-poll GET /workspace while a phase is mid-transition. */
const POLL_MS = 10000;
/** Phases mid-transition — the query keeps polling while the workspace sits here. */
export const isTransitionalStatus = (status: TWorkspaceStatus): boolean =>
- status === "provisioning" ||
- status === "stopping" ||
- status === "starting" ||
- status === "deleting";
+ status === WORKSPACE_STATUS.provisioning ||
+ status === WORKSPACE_STATUS.stopping ||
+ status === WORKSPACE_STATUS.starting ||
+ status === WORKSPACE_STATUS.deleting;
/**
* useWorkspaceQuery reads the singular workspace (SC-02). A 404 means "no
diff --git a/frontend/src/hooks/useBatchFailureModal.ts b/frontend/src/hooks/useBatchFailureModal.ts
new file mode 100644
index 0000000..28d378b
--- /dev/null
+++ b/frontend/src/hooks/useBatchFailureModal.ts
@@ -0,0 +1,43 @@
+import { useState } from "react";
+
+import { BATCH_REASON } from "@/constants/errorConstants";
+import type { TBatchResult } from "@/types/teamTypes";
+
+/** One row of MemberBatchFailureModal: target label + failure copy. */
+export type TBatchFailureRow = { account: string; reason: string };
+
+/**
+ * useBatchFailureModal owns the partial-failure surface shared by the
+ * batch endpoints (bulk role change, membership removal, user delete):
+ * non-null rows open MemberBatchFailureModal listing exactly what failed
+ * and why (API design — partial success is not an error).
+ */
+export const useBatchFailureModal = () => {
+ const [batchFailures, setBatchFailures] = useState(
+ null,
+ );
+
+ const closeBatchFailures = () => setBatchFailures(null);
+
+ return {
+ batchFailures,
+ showBatchFailures: setBatchFailures,
+ closeBatchFailures,
+ };
+};
+
+/**
+ * Maps a batch result's failures onto modal rows: a per-target label plus
+ * the shared BATCH_REASON copy. Unmapped codes fall back to whatever the
+ * caller chooses — the generic retry copy (role-change flows) or the raw
+ * backend code as a diagnostic hint (removal/delete flows).
+ */
+export const toBatchFailureRows = (
+ failed: TBatchResult["failed"],
+ labelOf: (id: string) => string,
+ fallbackFor: (code: string) => string,
+): TBatchFailureRow[] =>
+ failed.map((f) => ({
+ account: labelOf(f.id),
+ reason: BATCH_REASON[f.code] ?? fallbackFor(f.code),
+ }));
diff --git a/frontend/src/hooks/useMembershipDrafts.ts b/frontend/src/hooks/useMembershipDrafts.ts
new file mode 100644
index 0000000..8332ea0
--- /dev/null
+++ b/frontend/src/hooks/useMembershipDrafts.ts
@@ -0,0 +1,240 @@
+import { useState } from "react";
+
+import {
+ toBatchFailureRows,
+ useBatchFailureModal,
+} from "@/hooks/useBatchFailureModal";
+import { usePageScopedSelection } from "@/hooks/usePageScopedSelection";
+import { parseErrorCode } from "@/api/parseError";
+import { useNoticeStore } from "@/state/store/noticeStore";
+import { buildTeamOptions } from "@/utils/buildTeamOptions";
+import { getTeamDescendantIds } from "@/utils/teamHierarchy";
+import { ERROR_CODES } from "@/constants/apiConstants";
+import { NOTICE_TEXT } from "@/constants/noticeConstants";
+import type { TBatchResult, TTeamTree } from "@/types/teamTypes";
+import type { TUserListItem } from "@/types/userTypes";
+import { L } from "@/locales";
+
+/** One membership row as rendered: server truth (baseRole) with the
+ staged edits (role pick, checkbox) applied on top. */
+export type TMembershipDraft = {
+ teamId: string;
+ teamName: string;
+ baseRole: string;
+ role: string;
+ checked: boolean;
+};
+
+interface UseMembershipDraftsOptions {
+ user: TUserListItem;
+ /** Real team tree (GET /teams/tree) — add picker + sub-team notice. */
+ teams: TTeamTree;
+ onUpdateRoles: (
+ changes: { teamId: string; role: string }[],
+ ) => Promise;
+ onRemoveMemberships: (teamIds: string[]) => Promise;
+ onAddMembership: (teamId: string, role: string) => Promise;
+}
+
+/**
+ * useMembershipDrafts owns the SC-13 membership machine. Server truth
+ * (user.memberships) flows straight from props — never copied into
+ * state — so the fresher GET /users/{id} payload and every post-mutation
+ * refetch render immediately. Only the user's own edits are staged (role
+ * picks + checkbox selection), re-applied as a diff on top of whatever
+ * the server currently says. The confirm flows reconcile batch results:
+ * succeeded targets un-stage (the refetch delivers their new truth),
+ * failed ones stay staged/checked for a retry and surface in the
+ * batch-failure modal.
+ */
+export const useMembershipDrafts = ({
+ user,
+ teams,
+ onUpdateRoles,
+ onRemoveMemberships,
+ onAddMembership,
+}: UseMembershipDraftsOptions) => {
+ const [pendingRoles, setPendingRoles] = useState>(
+ new Map(),
+ );
+ const {
+ selectedIds: checkedIds,
+ toggleOne: setChecked,
+ toggleAll: setAllChecked,
+ setSelectedIds: setCheckedIds,
+ } = usePageScopedSelection();
+ const { batchFailures, showBatchFailures, closeBatchFailures } =
+ useBatchFailureModal();
+ const showNotice = useNoticeStore((state) => state.showNotice);
+
+ const memberships: TMembershipDraft[] = user.memberships.map((m) => ({
+ teamId: m.teamId,
+ teamName: m.teamName,
+ baseRole: m.role,
+ role: pendingRoles.get(m.teamId) ?? m.role,
+ checked: checkedIds.has(m.teamId),
+ }));
+
+ /* A staged pick equal to the (possibly refetched) server role is a
+ no-op and drops out of `changes` on its own. */
+ const changes = memberships.filter((m) => m.role !== m.baseRole);
+ const selected = memberships.filter((m) => m.checked);
+ const allChecked =
+ memberships.length > 0 && memberships.every((m) => m.checked);
+
+ /* Sub-team retention notice (SC-14 no.2): a selected team has a
+ descendant team whose membership stays after this removal. */
+ const remainingIds = memberships
+ .filter((m) => !m.checked)
+ .map((m) => m.teamId);
+ const subteamNotice = selected.some((m) =>
+ getTeamDescendantIds(teams, m.teamId).some((id) =>
+ remainingIds.includes(id),
+ ),
+ );
+
+ /* Failure rows are labeled by team name — the drawer's batch targets
+ are this one user's memberships. */
+ const teamNameOf = (teamId: string) =>
+ memberships.find((m) => m.teamId === teamId)?.teamName ?? teamId;
+
+ const stageRole = (teamId: string, role: string) =>
+ setPendingRoles((prev) => new Map(prev).set(teamId, role));
+ const resetStaged = () => setPendingRoles(new Map());
+
+ /* ── [팀 추가하기] picker row (SC-13 no.2) ─────────────────────── */
+ const [addOpen, setAddOpen] = useState(false);
+ const [addTeamId, setAddTeamId] = useState("");
+ const [addRole, setAddRole] = useState("");
+ const [adding, setAdding] = useState(false);
+
+ /* Teams the user already belongs to stay out of the add picker.
+ Depth indent stripped — the narrow drawer dropdown can't fit
+ deep-tree indentation. */
+ const joinedIds = new Set(memberships.map((m) => m.teamId));
+ const addableTeams = buildTeamOptions(teams)
+ .filter((o) => !joinedIds.has(o.value))
+ .map(({ value, label }) => ({ value, label }));
+
+ const resetAdd = () => {
+ setAddOpen(false);
+ setAddTeamId("");
+ setAddRole("");
+ };
+ const toggleAddRow = () => (addOpen ? resetAdd() : setAddOpen(true));
+
+ const handleAdd = async () => {
+ setAdding(true);
+ try {
+ /* The mutation invalidates the user detail/list queries — the new
+ row arrives with the refetch, so nothing is mirrored locally. */
+ await onAddMembership(addTeamId, addRole);
+ showNotice(
+ NOTICE_TEXT.addMembership.title,
+ NOTICE_TEXT.addMembership.success,
+ "info",
+ );
+ resetAdd();
+ } catch (err) {
+ const code = err instanceof Response ? await parseErrorCode(err) : "";
+ showNotice(
+ NOTICE_TEXT.addMembership.title,
+ code === ERROR_CODES.ALREADY_TEAM_MEMBER
+ ? NOTICE_TEXT.addMembership.alreadyMember
+ : NOTICE_TEXT.addMembership.failure,
+ "error",
+ );
+ } finally {
+ setAdding(false);
+ }
+ };
+
+ /* ── confirm flows (RoleChangeConfirmModal / MembershipRemoveModal) ── */
+ const confirmRoleChanges = async () => {
+ const changedIds = changes.map((m) => m.teamId);
+ const result = await onUpdateRoles(
+ changes.map((m) => ({ teamId: m.teamId, role: m.role })),
+ );
+ const failedIds = new Set(result.failed.map((f) => f.id));
+ /* Applied roles come back with the invalidation refetch — drop their
+ staged picks and keep only the failed ones staged for a retry. */
+ setPendingRoles((prev) => {
+ const next = new Map(prev);
+ for (const teamId of changedIds) {
+ if (!failedIds.has(teamId)) next.delete(teamId);
+ }
+ return next;
+ });
+ if (result.failed.length > 0) {
+ showBatchFailures(
+ toBatchFailureRows(
+ result.failed,
+ teamNameOf,
+ () => L.common.processFailed,
+ ),
+ );
+ }
+ };
+
+ const confirmRemovals = async () => {
+ const removedIds = selected.map((m) => m.teamId);
+ const result = await onRemoveMemberships(removedIds);
+ const failedIds = new Set(result.failed.map((f) => f.id));
+ /* Removed rows drop out with the invalidation refetch — clear their
+ staged edits; failed rows keep their check for a retry. */
+ setCheckedIds((prev) => {
+ const next = new Set(prev);
+ for (const teamId of removedIds) {
+ if (!failedIds.has(teamId)) next.delete(teamId);
+ }
+ return next;
+ });
+ setPendingRoles((prev) => {
+ const next = new Map(prev);
+ for (const teamId of removedIds) {
+ if (!failedIds.has(teamId)) next.delete(teamId);
+ }
+ return next;
+ });
+ if (result.failed.length === 0) {
+ showNotice(
+ NOTICE_TEXT.removeMembership.title,
+ NOTICE_TEXT.removeMembership.success,
+ "success",
+ );
+ } else {
+ showBatchFailures(
+ toBatchFailureRows(
+ result.failed,
+ teamNameOf,
+ () => L.common.processFailed,
+ ),
+ );
+ }
+ };
+
+ return {
+ memberships,
+ changes,
+ selected,
+ allChecked,
+ subteamNotice,
+ stageRole,
+ setChecked,
+ setAllChecked,
+ resetStaged,
+ addOpen,
+ addTeamId,
+ addRole,
+ adding,
+ addableTeams,
+ setAddTeamId,
+ setAddRole,
+ toggleAddRow,
+ handleAdd,
+ confirmRoleChanges,
+ confirmRemovals,
+ batchFailures,
+ closeBatchFailures,
+ };
+};
diff --git a/frontend/src/hooks/usePageScopedSelection.ts b/frontend/src/hooks/usePageScopedSelection.ts
new file mode 100644
index 0000000..dbd17c9
--- /dev/null
+++ b/frontend/src/hooks/usePageScopedSelection.ts
@@ -0,0 +1,38 @@
+import { useState } from "react";
+
+/**
+ * usePageScopedSelection owns a checkbox column's Set-of-ids selection
+ * (users page, team member table, drawer membership rows).
+ *
+ * "Page-scoped" is a caller contract: whatever changes the visible rows
+ * (page move, filter change, team switch) should call clearSelection so a
+ * checked row never rides along into a bulk action taken on a different
+ * slice. setSelectedIds is exposed for batch-result reconciliation —
+ * dropping succeeded targets while failed ones stay selected for a retry.
+ */
+export const usePageScopedSelection = () => {
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+
+ const toggleOne = (id: string, selected: boolean) =>
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (selected) next.add(id);
+ else next.delete(id);
+ return next;
+ });
+
+ /** Header select-all: add/remove the given (visible) ids in one shot. */
+ const toggleAll = (ids: string[], selected: boolean) =>
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ ids.forEach((id) => {
+ if (selected) next.add(id);
+ else next.delete(id);
+ });
+ return next;
+ });
+
+ const clearSelection = () => setSelectedIds(new Set());
+
+ return { selectedIds, toggleOne, toggleAll, clearSelection, setSelectedIds };
+};
diff --git a/frontend/src/hooks/useServerPagination.ts b/frontend/src/hooks/useServerPagination.ts
new file mode 100644
index 0000000..9efab16
--- /dev/null
+++ b/frontend/src/hooks/useServerPagination.ts
@@ -0,0 +1,47 @@
+import { useCallback, useEffect, useState } from "react";
+
+import { DEFAULT_PAGE_SIZE } from "@/constants/commonConstants";
+
+/**
+ * useServerPagination owns the page state for a server-paged table.
+ *
+ * totalPages tracks the last response's total (kept as state, not derived,
+ * so a page/sort transition under keepPreviousData never flashes an interim
+ * value), and the returned `page` is clamped against it BEFORE the query
+ * call — an out-of-range request never fires. When a response shrinks the
+ * range (filter/sort change, deletions emptying the last page), the stored
+ * page is corrected so Pagination and later renders resume from a valid
+ * value instead of the stale, too-high one.
+ *
+ * Wiring: pass `page` to the list query, then report each response's total
+ * back with one effect — `useEffect(() => syncTotal(total), [total,
+ * syncTotal])`.
+ */
+export const useServerPagination = (pageSize: number = DEFAULT_PAGE_SIZE) => {
+ const [rawPage, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const page = Math.min(rawPage, totalPages);
+
+ const syncTotal = useCallback(
+ (total: number) => {
+ const next = Math.max(1, Math.ceil(total / pageSize));
+ setTotalPages(next);
+ setPage((prev) => Math.min(prev, next));
+ },
+ [pageSize],
+ );
+
+ const resetPage = useCallback(() => setPage(1), []);
+
+ return { page, totalPages, setPage, resetPage, syncTotal, pageSize };
+};
+
+/** Companion one-liner so callers don't hand-roll the report-back effect. */
+export const useSyncPaginationTotal = (
+ syncTotal: (total: number) => void,
+ total: number,
+) => {
+ useEffect(() => {
+ syncTotal(total);
+ }, [syncTotal, total]);
+};
diff --git a/frontend/src/hooks/useStagedRoleEdits.ts b/frontend/src/hooks/useStagedRoleEdits.ts
new file mode 100644
index 0000000..0ad4f57
--- /dev/null
+++ b/frontend/src/hooks/useStagedRoleEdits.ts
@@ -0,0 +1,78 @@
+import { useState } from "react";
+
+import type { TTeamMemberRole } from "@/types/teamTypes";
+
+/**
+ * useStagedRoleEdits owns the SC-06 staged role-edit machine: dropdown
+ * picks collect in pendingRoles and only apply on [변경사항 업데이트];
+ * savedRoles is the committed baseline shown until the invalidation
+ * refetch delivers the server truth (the list query keeps previous data
+ * visible during the refetch).
+ */
+export const useStagedRoleEdits = () => {
+ const [pendingRoles, setPendingRoles] = useState<
+ Map
+ >(new Map());
+ const [savedRoles, setSavedRoles] = useState>(
+ new Map(),
+ );
+
+ /** Committed role for a member — the staged baseline or the wire value. */
+ const baseRole = (userId: string, fallback: TTeamMemberRole) =>
+ savedRoles.get(userId) ?? fallback;
+
+ /** Stage a dropdown pick; picking the base value back un-stages it. */
+ const stageRole = (
+ userId: string,
+ fallback: TTeamMemberRole,
+ nextRole: string,
+ ) =>
+ setPendingRoles((prev) => {
+ const next = new Map(prev);
+ if (nextRole === baseRole(userId, fallback)) next.delete(userId);
+ else next.set(userId, nextRole as TTeamMemberRole);
+ return next;
+ });
+
+ /** [변경사항 초기화] — staged picks drop; the committed baseline stays. */
+ const resetStaged = () => setPendingRoles(new Map());
+
+ /** Team switch — nothing staged or committed may leak across teams. */
+ const resetAll = () => {
+ setPendingRoles(new Map());
+ setSavedRoles(new Map());
+ };
+
+ /** Full batch success — commit every staged pick into the baseline. */
+ const applyAll = () => {
+ setSavedRoles((prev) => new Map([...prev, ...pendingRoles]));
+ setPendingRoles(new Map());
+ };
+
+ /** Partial batch failure — commit only what succeeded and keep the
+ failed entries staged so the user can retry them. */
+ const reconcileBatch = (failedIds: Set) => {
+ setSavedRoles(
+ (prev) =>
+ new Map([
+ ...prev,
+ ...[...pendingRoles.entries()].filter(
+ ([userId]) => !failedIds.has(userId),
+ ),
+ ]),
+ );
+ setPendingRoles(
+ (prev) => new Map([...prev].filter(([userId]) => failedIds.has(userId))),
+ );
+ };
+
+ return {
+ pendingRoles,
+ baseRole,
+ stageRole,
+ resetStaged,
+ resetAll,
+ applyAll,
+ reconcileBatch,
+ };
+};
diff --git a/frontend/src/hooks/useTeamCrud.ts b/frontend/src/hooks/useTeamCrud.ts
new file mode 100644
index 0000000..9b1bb80
--- /dev/null
+++ b/frontend/src/hooks/useTeamCrud.ts
@@ -0,0 +1,119 @@
+import { useState } from "react";
+
+import {
+ useCreateTeamMutation,
+ useDeleteTeamMutation,
+ useRenameTeamMutation,
+} from "@/hooks/mutations/useTeamMutations";
+import { parseErrorCode } from "@/api/parseError";
+import { useNoticeStore } from "@/state/store/noticeStore";
+import { TEAM_REASON } from "@/constants/errorConstants";
+import { NOTICE_TEXT } from "@/constants/noticeConstants";
+import { L } from "@/locales";
+
+interface UseTeamCrudOptions {
+ /** Rename/delete target — pass "" when only the create flow is used
+ (the mutations are lazy, so an unused id never fires). */
+ teamId: string;
+ /** Close the owning modal after a successful mutation. */
+ onDone: () => void;
+ /** Post-delete hand-off (SC-08 — reselect another root team). */
+ onDeleted?: () => void;
+}
+
+/**
+ * useTeamCrud owns the team create/rename/delete orchestration shared by
+ * TreeDetailView (SC-07~09) and TeamsPage's empty-state create (SC-06 B):
+ * one TEAM_REASON error mapping into the modals' inline error, one
+ * success-notice wiring. teamError is reset on every attempt; callers
+ * clear it when opening/closing a modal so a stale error never leaks
+ * into a fresh one.
+ */
+export const useTeamCrud = ({
+ teamId,
+ onDone,
+ onDeleted,
+}: UseTeamCrudOptions) => {
+ const [teamError, setTeamError] = useState(null);
+ const createTeam = useCreateTeamMutation();
+ const renameTeam = useRenameTeamMutation(teamId);
+ const deleteTeam = useDeleteTeamMutation(teamId);
+ const showNotice = useNoticeStore((state) => state.showNotice);
+
+ const clearTeamError = () => setTeamError(null);
+
+ const handleCreate = (name: string, parentId: string | null) => {
+ setTeamError(null);
+ createTeam.mutate(
+ { name, parentId },
+ {
+ onSuccess: () => {
+ onDone();
+ showNotice(
+ NOTICE_TEXT.createTeam.title,
+ NOTICE_TEXT.createTeam.success,
+ "success",
+ );
+ },
+ onError: async (res) => {
+ const code = await parseErrorCode(res);
+ setTeamError(TEAM_REASON[code] ?? L.teams.createTeamFailed);
+ },
+ },
+ );
+ };
+
+ const handleRename = (name: string) => {
+ setTeamError(null);
+ renameTeam.mutate(
+ { name },
+ {
+ onSuccess: () => {
+ onDone();
+ showNotice(
+ NOTICE_TEXT.renameTeam.title,
+ NOTICE_TEXT.renameTeam.success,
+ "success",
+ );
+ },
+ onError: async (res) => {
+ const code = await parseErrorCode(res);
+ setTeamError(TEAM_REASON[code] ?? L.teams.renameFailed);
+ },
+ },
+ );
+ };
+
+ const handleDelete = (
+ action: "purge" | "transfer",
+ targetTeamId?: string,
+ ) => {
+ setTeamError(null);
+ deleteTeam.mutate(
+ { memoryAction: action, targetTeamId },
+ {
+ onSuccess: () => {
+ onDone();
+ showNotice(
+ NOTICE_TEXT.deleteTeam.title,
+ NOTICE_TEXT.deleteTeam.success,
+ "success",
+ onDeleted,
+ );
+ },
+ onError: async (res) => {
+ const code = await parseErrorCode(res);
+ setTeamError(TEAM_REASON[code] ?? L.teams.deleteFailed);
+ },
+ },
+ );
+ };
+
+ return {
+ teamError,
+ clearTeamError,
+ handleCreate,
+ handleRename,
+ handleDelete,
+ };
+};
diff --git a/frontend/src/hooks/useUserBatchActions.ts b/frontend/src/hooks/useUserBatchActions.ts
new file mode 100644
index 0000000..536a866
--- /dev/null
+++ b/frontend/src/hooks/useUserBatchActions.ts
@@ -0,0 +1,149 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import {
+ useDeleteUsers,
+ useInviteMutation,
+ useResendInvitation,
+} from "@/hooks/mutations/useInvitationMutations";
+import {
+ toBatchFailureRows,
+ useBatchFailureModal,
+} from "@/hooks/useBatchFailureModal";
+import { parseErrorCode } from "@/api/parseError";
+import { useNoticeStore } from "@/state/store/noticeStore";
+import { ERROR_CODES } from "@/constants/apiConstants";
+import { NOTICE_TEXT } from "@/constants/noticeConstants";
+import type { TTeamMemberRole } from "@/types/teamTypes";
+import type {
+ TInvitePayload,
+ TInviteResult,
+ TUserListItem,
+} from "@/types/userTypes";
+
+interface UseUserBatchActionsOptions {
+ /** Selection reconciliation — deleted targets drop out, failed ones
+ stay selected for a retry. */
+ setSelectedIds: Dispatch>>;
+ /** Called with the ids that were actually deleted (drawer close-out). */
+ onDeleted: (deletedIds: string[]) => void;
+}
+
+/**
+ * useUserBatchActions owns the SC-11 bulk flows — invite (SC-12), invite-
+ * code resend, and batch delete (SC-15) — including their notice/batch-
+ * failure surfaces. Pure orchestration over the invitation mutations; the
+ * page supplies selection reconciliation and the drawer close-out.
+ */
+export const useUserBatchActions = ({
+ setSelectedIds,
+ onDeleted,
+}: UseUserBatchActionsOptions) => {
+ const invite = useInviteMutation();
+ const resend = useResendInvitation();
+ const deleteUsersMutation = useDeleteUsers();
+ const { batchFailures, showBatchFailures, closeBatchFailures } =
+ useBatchFailureModal();
+ const showNotice = useNoticeStore((state) => state.showNotice);
+
+ /** POST /invitations — server judges duplicates and target states; only
+ the staged team/role sets are sent (buildInvitePreview's sub-team
+ expansion is display-only, the server performs the real expansion). */
+ const inviteMember = async (
+ payload: TInvitePayload,
+ ): Promise => {
+ try {
+ await invite.mutateAsync({
+ account: payload.email,
+ username: payload.username,
+ memberships: payload.sets.map((set) => ({
+ teamId: set.teamId,
+ role: set.role as TTeamMemberRole,
+ })),
+ });
+ return "success";
+ } catch (err) {
+ if (err instanceof Response) {
+ const code = await parseErrorCode(err);
+ return code === ERROR_CODES.ALREADY_TEAM_MEMBER
+ ? "duplicate-account"
+ : "error";
+ }
+ return "error";
+ }
+ };
+
+ /** POST /invitations/resend for one account (drawer action). */
+ const resendCode = (userId: string) => resend.mutateAsync(userId);
+
+ /** POST /invitations/resend (per target) — status never changes (D10).
+ Selection stays intact on partial failure so the user can retry. */
+ const resendCodes = async (targets: TUserListItem[]) => {
+ const results = await Promise.allSettled(
+ targets.map((u) => resend.mutateAsync(u.userId)),
+ );
+ const failed = targets.filter((_, i) => results[i].status === "rejected");
+ if (failed.length === 0) {
+ showNotice(
+ NOTICE_TEXT.resendInvitation.title,
+ NOTICE_TEXT.resendInvitation.success,
+ "info",
+ );
+ return;
+ }
+ showBatchFailures(
+ failed.map((u) => ({
+ account: u.account,
+ reason: NOTICE_TEXT.resendInvitation.failedReason,
+ })),
+ );
+ };
+
+ /** DELETE /users (batch) — memberships, session token, and unused
+ invite codes go together (D13). Full success clears the targets
+ from selection and closes the drawer if it pointed at one of
+ them; partial failure shows the failure modal (account + reason)
+ and leaves the still-failed ids selected for retry. Throws only
+ on full failure, so MemberDeleteModal/the drawer's onDeleteMember
+ contract (resolve unless every target failed) is unaffected. */
+ const deleteMembers = async (targets: TUserListItem[]) => {
+ const userIds = targets.map((u) => u.userId);
+ const result = await deleteUsersMutation.mutateAsync(userIds);
+ const failedIds = new Set(result.failed.map((f) => f.id));
+ const succeededIds = userIds.filter((id) => !failedIds.has(id));
+
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ succeededIds.forEach((id) => next.delete(id));
+ return next;
+ });
+ onDeleted(succeededIds);
+
+ if (result.failed.length === 0) {
+ showNotice(
+ NOTICE_TEXT.deleteMember.title,
+ NOTICE_TEXT.deleteMember.success,
+ "info",
+ );
+ return;
+ }
+ if (succeededIds.length === 0) {
+ throw new Error("delete failed for every target");
+ }
+ showBatchFailures(
+ toBatchFailureRows(
+ result.failed,
+ (id) => targets.find((u) => u.userId === id)?.account ?? id,
+ (code) => code,
+ ),
+ );
+ };
+
+ return {
+ inviteMember,
+ resendCode,
+ resendCodes,
+ deleteMembers,
+ batchFailures,
+ closeBatchFailures,
+ };
+};
diff --git a/frontend/src/locales/__tests__/locales.test.ts b/frontend/src/locales/__tests__/locales.test.ts
new file mode 100644
index 0000000..dd3b51a
--- /dev/null
+++ b/frontend/src/locales/__tests__/locales.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+
+import { BTN_TEXT, MODAL_TITLES, NAV_LIST } from "@/constants/commonConstants";
+import { WORKSPACE_STATUS_VAR } from "@/constants/styleConstants";
+import { L, language } from "@/locales";
+import { en } from "@/locales/en";
+import { ko } from "@/locales/ko";
+
+/* Key parity between en and ko is enforced at COMPILE time (ko is typed as
+ * TLocale = typeof en), so a drifted key never survives tsc. The runtime
+ * checks here cover what types can't: the actual copy and interpolation. */
+
+describe("locales", () => {
+ it("resolves Korean under the test pin (setup.ts sets rc_lang=ko)", () => {
+ expect(language).toBe("ko");
+ // L is now a Proxy over the active table (not the raw object), so compare
+ // by structure rather than reference.
+ expect(L).toStrictEqual(ko);
+ expect(BTN_TEXT.close).toBe("닫기");
+ expect(MODAL_TITLES.deleteTeam("platform")).toBe("팀 삭제 — platform");
+ expect(MODAL_TITLES.deleteMemberBulk(3)).toBe("멤버 삭제 (3명)");
+ expect(NAV_LIST[0].title).toBe("팀 관리");
+ expect(WORKSPACE_STATUS_VAR.running.label).toBe("실행 중");
+ });
+
+ it("carries the English copy with its own word order", () => {
+ expect(en.btn.close).toBe("Close");
+ expect(en.btn.resendInvitationCode).toBe("Resend Invitation Code");
+ expect(en.modal.deleteTeam("platform")).toBe("Delete Team — platform");
+ expect(en.modal.deleteMemberBulk(3)).toBe("Delete Members (3)");
+ expect(en.nav.teams).toBe("Team Management");
+ expect(en.status.workspace.running).toBe("Running");
+ expect(en.status.invitation.pending).toBe("Invitation Pending");
+ });
+
+ it("keeps both locales structurally identical at runtime too", () => {
+ const shape = (obj: object, prefix = ""): string[] =>
+ Object.entries(obj)
+ .flatMap(([k, v]) =>
+ typeof v === "object" && v !== null
+ ? shape(v, `${prefix}${k}.`)
+ : [`${prefix}${k}:${typeof v}`],
+ )
+ .sort();
+ expect(shape(en)).toEqual(shape(ko));
+ });
+});
diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts
new file mode 100644
index 0000000..e678511
--- /dev/null
+++ b/frontend/src/locales/en.ts
@@ -0,0 +1,399 @@
+/**
+ * English UI copy. This module is the locale SHAPE's source of truth: ko.ts
+ * must satisfy TLocale, so a key added or removed here is a compile error
+ * there — the two languages cannot drift apart silently.
+ *
+ * Strings that embed a value are plain functions, so each language controls
+ * its own word order (Korean particles and counters don't map 1:1 to English).
+ */
+export const en = {
+ btn: {
+ // Generic actions
+ close: "Close",
+ cancel: "Cancel",
+ confirm: "Confirm",
+ save: "Save",
+ delete: "Delete",
+ remove: "Remove",
+ add: "Add",
+ create: "Create",
+ change: "Change",
+ refresh: "Refresh",
+ retry: "Retry",
+ update: "Update",
+ later: "Later",
+ // Auth / navigation
+ signOut: "Sign Out",
+ getStarted: "Get Started",
+ login: "Log In",
+ home: "Go to Home",
+ // Workspace
+ restart: "Restart",
+ stop: "Stop",
+ deactivate: "Deactivate",
+ recreate: "Delete and Recreate",
+ reconnect: "Reconnect",
+ // Teams
+ createTeam: "Create New Team",
+ createGroup: "Create Team",
+ rename: "Rename",
+ deleteTeam: "Delete Team",
+ addMember: "Add Member",
+ addTeam: "Add Team",
+ // Members / invitations
+ invite: "Invite",
+ inviteMember: "Invite Member",
+ sendInvitation: "Send Invitation",
+ resendInvitationCode: "Resend Invitation Code",
+ cancelInvitation: "Cancel Invitation",
+ cancelAction: "Cancel",
+ addTeamRole: "+ Add Team/Role",
+ removeRow: "✕",
+ updateChanges: "Update Changes",
+ resetChanges: "Reset Changes",
+ deactivateSession: "Deactivate Session",
+ deleteMember: "Delete Member",
+ },
+ modal: {
+ // Workspace
+ workspaceManage: "Manage Workspace",
+ workspaceDelete: "Delete Workspace",
+ workspaceOrphaned: "Workspace Recreation Required",
+ workspaceReconnect: "Workspace Reconnection Required",
+ // Teams
+ createTeam: "Create New Team",
+ renameTeam: "Rename Team",
+ deleteTeam: (teamName: string) => `Delete Team — ${teamName}`,
+ addMember: (teamName: string) => `Add Member — ${teamName}`,
+ batchFailure: "Some items could not be processed",
+ // Members / roles / invitations
+ roleChange: "Change Role",
+ removeMembership: "Remove Membership",
+ inviteMember: "Invite Member",
+ cancelInvitation: "Cancel Invitation",
+ deactivateSession: "Deactivate Session",
+ deleteMemberSingle: (account: string) => `Delete Member — ${account}`,
+ deleteMemberBulk: (count: number) => `Delete Members (${count})`,
+ },
+ nav: {
+ teams: "Team Management",
+ users: "Member Management",
+ sessions: "Session Management",
+ },
+ login: {
+ failed1: "There was a problem signing in.",
+ failed2: "Please try again.",
+ accountRequired: "A RUNE account is required to use the console.",
+ },
+ status: {
+ member: {
+ online: "Online",
+ offline: "Offline",
+ },
+ invitation: {
+ pending: "Invitation Pending",
+ expired: "Invitation Code Expired",
+ redeemed: "Invitation code sent",
+ },
+ workspace: {
+ provisioning: "Creating",
+ running: "Running",
+ stopping: "Stopping",
+ stopped: "Stopped",
+ starting: "Restarting",
+ deleting: "Deleting",
+ error: "Unavailable",
+ },
+ },
+ profile: {
+ menu: "Profile menu",
+ image: "Profile image",
+ plan: "Plan: Free",
+ },
+ common: {
+ none: "None",
+ loading: "Loading…",
+ team: "Team",
+ role: "Role",
+ memberName: "Member Name",
+ memberStatus: "Member Status",
+ selectAll: "Select All",
+ select: "Select",
+ all: "All",
+ users: "Users",
+ sort: "Sort",
+ sortBy: "Sort by",
+ noResults: "No results found.",
+ refreshRetry: "Refresh and try again.",
+ tryAgainLater: "Please try again later.",
+ processFailed: "Processing failed. Please try again.",
+ selectName: (name: string) => `Select ${name}`,
+ },
+ elements: {
+ pagination: "Pagination",
+ prevPage: "Previous page",
+ nextPage: "Next page",
+ search: "Search",
+ clear: "Clear",
+ clickToDismiss: "Click to dismiss",
+ unsavedChanges: "Unsaved changes",
+ },
+ auth: {
+ ownerLockedTitle: "Account Not Available",
+ // Sentence split around the styled owner element: prefix + owner + suffix.
+ ownerLockedPrefix: "This console is managed by ",
+ ownerLockedSuffix: ".",
+ ownerFallback: "another account",
+ ownerLockedBody: "You can't use the console with this account.",
+ },
+ teams: {
+ // Add-member modal
+ emailLabel: "Email (account)",
+ invalidEmail: "Invalid email format.",
+ usernameLabel: "Username",
+ usernamePlaceholder: "Username",
+ roleLabel: "Role",
+ selectRole: "Select Role",
+ // Sentence split around the inline member-status chip: prefix + status + suffix.
+ invitePrefix:
+ "Once the invited user connects RUNE, their status changes to ",
+ inviteSuffix: ".",
+ // Team name / create / rename
+ teamNameRule:
+ "Only numbers, Korean and English letters, - and _ are allowed.",
+ teamName: "Team Name",
+ teamNamePlaceholder: "e.g. platform-team",
+ parentTeamOptional: "Parent Team (Optional)",
+ selectTeam: "Select Team",
+ parentCopyInfo1:
+ "Selecting a parent team automatically copies its members to the new team.",
+ parentCopyInfo2:
+ "You can edit members on the detail page after the team is created.",
+ dupName: "A team with this name already exists under the same parent team.",
+ invalidTeamName: "Invalid team name format.",
+ // Delete-team modal
+ hasChildrenAlert1: "Teams with sub-teams cannot be deleted.",
+ hasChildrenAlert2: "Delete the sub-teams first, then try again.",
+ memoryChoiceInfo:
+ "Choose how to handle this team's memory before deleting.",
+ transferOption: "① Transfer to Another Team",
+ defaultTag: "(Default)",
+ destinationTeam: "Destination Team",
+ confirmTargetLabel: "Confirm — Enter Target Team Name",
+ selectTeamFirst: "Select a team first",
+ targetNameMismatch: "Target team name does not match.",
+ purgeOption: "② Delete This Team's Memory",
+ purgeDesc:
+ "Memories shared with other teams will remain accessible from those teams.",
+ confirmDeleteLabel: "Confirm — Enter Team Name to Delete",
+ nameMismatch: "Team name does not match.",
+ cannotDeleteHasChildren: "Cannot delete a team that has sub-teams.",
+ // Org chart
+ viewDetails: (name: string) => `View ${name} details`,
+ zoomOut: "Zoom Out",
+ zoomIn: "Zoom In",
+ // Remove-membership / role-change modals
+ removeIntro: "The following memberships will be removed:",
+ removeKeepSubteams:
+ "Sub-team memberships are kept. Select them individually to remove if needed.",
+ roleChangeIntro: "The following members' roles will be changed:",
+ roleChangeCol: "Role Change",
+ // Tree & detail view
+ teamTree: "Team Tree",
+ teamMeta: (parent: string, children: string, count: number, date: string) =>
+ `Parent team: ${parent} | Sub-teams: ${children} | Members: ${count} | Created: ${date}`,
+ countItems: (count: number) => `${count}`,
+ membersHeading: (total: number) => `Members (${total})`,
+ memberPageInfo: (total: number, pageSize: number) =>
+ `${total} total · ${pageSize} per page`,
+ roleHeader: "Role",
+ joinedAt: "Joined",
+ membersLoadError: "Unable to load the member list.",
+ noMembers: "No members.",
+ changesSaved: "Changes saved.",
+ // Notices (team CRUD / member ops)
+ createTeamTitle: "Create Team",
+ teamCreated: "Team created.",
+ createTeamFailed: "Failed to create the team.",
+ teamRenamed: "Team name changed.",
+ renameFailed: "Failed to rename the team.",
+ deleteTeamTitle: "Delete Team",
+ teamDeleted: "Team deleted.",
+ deleteFailed: "Failed to delete the team.",
+ addMemberTitle: "Add Member",
+ memberAdded: "Member added.",
+ addMemberFailed: "Failed to add the member.",
+ roleChangeFailed: "Failed to change roles.",
+ membershipsRemoved: "Memberships removed.",
+ removeFailed: "Failed to remove memberships.",
+ // Server error reasons
+ userNotFound: "User not found",
+ notTeamMember: "Not a team member",
+ alreadyInvited: "This user has already been invited.",
+ notRegistered: "This account is not registered.",
+ cannotAddAdmin: "Console admin accounts cannot be added.",
+ inviteSendFailed: "Failed to send the invite code. Please try again.",
+ // Team tree / nodes
+ checkTeamName: "Check the team name and try again.",
+ collapseName: (name: string) => `Collapse ${name}`,
+ expandName: (name: string) => `Expand ${name}`,
+ // Teams page
+ switchView: "Switch view",
+ treeDetail: "Tree & Detail",
+ orgChartView: "Org Chart",
+ searchTeams: "Search teams",
+ teamsLoadError: "Couldn't load teams.",
+ emptyTitle: "Create your first team.",
+ emptyDesc: "Create a team to manage members and memory.",
+ },
+ members: {
+ // Invite modal (SC-12)
+ emailFormatError: "Please enter a valid email address",
+ duplicateAccount:
+ "This account is already registered. Use Add Member or Resend Invitation Code instead.",
+ sendFailed: "Failed to send invitation. Please try again.",
+ teamRole: "Team / Role",
+ selectTeam: "Select team",
+ selectRole: "Select role",
+ setTeamAria: (n: number) => `Set ${n} team`,
+ setRoleAria: (n: number) => `Set ${n} role`,
+ subteamPreview: "Sub-Team Role Preview",
+ reason: "Reason",
+ // Sentence split around the inline member-status chip: prefix + status + suffix.
+ invitePrefix:
+ "When invited, the user receives an invitation code; once they connect RUNE within 24 hours, their status changes to ",
+ inviteSuffix: ".",
+ inviteExpiry:
+ "If they do not connect, the code expires and you can invite them again with Resend Invitation Code.",
+ // Invite preview rows (invitePreview.ts)
+ directlyAssigned: "Directly assigned",
+ teamAlreadyInvited: "Team already invited.",
+ subteamOf: (teamName: string) => `Sub-team of ${teamName}`,
+ // Cancel-invitation modal (D15)
+ cancelInviteExpires: (account: string) =>
+ `All unused invitation codes for ${account} will expire.`,
+ userNotDeleted: "The user will not be deleted.",
+ // Session-deactivate modal (D12)
+ deactivateConfirm: (account: string) =>
+ `Deactivate the session for ${account}?`,
+ allMcpTerminated: "All MCP sessions will be terminated.",
+ // Member-delete modal (SC-15)
+ deleteFailed: "Failed to delete member. Please try again.",
+ noTeams: "Not a member of any team.",
+ deleteSingleIntro: (account: string) =>
+ `The account ${account} will be deleted and removed from the teams below:`,
+ deleteBulkIntro:
+ "The following members' accounts will be deleted and removed from the teams below:",
+ // Membership-remove / role-change modals
+ removeFailed: "Failed to remove membership. Please try again.",
+ roleChangeFailedRetry: "Failed to change role. Please try again.",
+ roleChanged: "Role changed.",
+ // Member-detail drawer (SC-13)
+ lastAccessed: (date: string) => `Last accessed ${date}`,
+ redeemedAwaiting: "Invitation code used · Awaiting connection",
+ lastInviteSentAt: (datetime: string) =>
+ `Last invitation code sent ${datetime}`,
+ teamNotFound: "Team not found",
+ inviteCodeResent: "Invitation code resent.",
+ resendCodeFailed: "Failed to resend invitation code. Please try again.",
+ addTeamTitle: "Add Team",
+ addedToTeam: "Added to team.",
+ alreadyTeamMember: "Already a member of this team.",
+ addTeamFailed: "Failed to add team. Please try again.",
+ teamsHeading: (count: number) => `Teams (${count})`,
+ selectAll: "Select all",
+ noTeamsToAdd: "No teams to add",
+ teamToAdd: "Team to add",
+ roleToAdd: "Role to add",
+ sessionDeactivated: "Session deactivated.",
+ sessionAlreadyExpired: "This session has already expired.",
+ deactivateFailed: "Failed to deactivate session. Please try again.",
+ invitationCanceled: "Invitation canceled.",
+ noInvitationToCancel: "No invitation to cancel.",
+ cancelInvitationFailed: "Failed to cancel invitation. Please try again.",
+ // Users page (SC-11)
+ lastInviteSent: "Invitation Code",
+ resendFailedShort: "Resend failed",
+ deleteMembersTitle: "Delete Members",
+ membersDeleted: "Members deleted.",
+ membersLoadError: "Couldn't load members.",
+ noMembersYet: "No members invited yet",
+ inviteHint: "Invite a member and an invitation code will be sent by email",
+ searchByName: "Search by name",
+ statusFilterAria: "Status filter",
+ teamFilterAria: "Team filter",
+ teamRoleHeader: "Team (Role)",
+ // Sessions page (SC-16)
+ lastIssued: "Last Issued",
+ lastAccessedAt: "Last Accessed",
+ historyLoadError: "Couldn't load history.",
+ sessionPageInfo: (total: number, pageSize: number) =>
+ `${total} total · ${pageSize} per page`,
+ issuedAt: "Issued At",
+ noHistory: "No history.",
+ },
+ workspace: {
+ // Navbar badge (SC-03 callout 2)
+ badgeLabel: "Workspace",
+ badgeRecreate: "Recreation Required",
+ badgeReconnect: "Reconnection Required",
+ badgeNone: "No Workspace",
+ // Management modal (SC-02 state D + variants)
+ stopFailed: "Failed to stop workspace. Please try again.",
+ restartFailed: "Failed to restart workspace. Please try again.",
+ recreateFailed: "Failed to recreate workspace. Please try again.",
+ deleteFailed: "Failed to delete workspace. Please try again.",
+ deleteConfirm: "Delete this workspace?",
+ deleteIrreversible: "This action cannot be undone.",
+ tearingDown1: "Deleting the existing workspace…",
+ tearingDown2: "Workspace creation will start once deletion is complete.",
+ orphaned1:
+ "The console was reinstalled and can no longer connect to this workspace.",
+ orphaned2:
+ "Previously stored data is encrypted with the old security key and cannot be recovered.",
+ orphaned3: "Delete and recreate to start over with an empty workspace.",
+ reconnectExpired: "The workspace connection has expired.",
+ reconnectPrompt: "Reconnect to reactivate the data plane.",
+ reconnectFailed: "Failed to reconnect. Please try again.",
+ loadFailed: "Unable to load workspace information.",
+ plan: "Plan",
+ statusLabel: "Status",
+ storedMemories: "Stored Memories",
+ // Empty-workspace page (SC-02 states A/B/C)
+ pageAria: "Workspace management",
+ creatingTitle: "Creating your workspace…",
+ creatingDesc: "This takes about 3–5 minutes.",
+ createFailedTitle: "Workspace Creation Failed",
+ createFailedDesc: "Couldn't create the workspace. Please try again.",
+ createWorkspace: "Create Workspace",
+ emptyTitle: "No workspace has been created.",
+ emptyDesc: "Create a workspace to store memory.",
+ // Console update floating card
+ updatingTitle: "Updating the Console",
+ updateFailedTitle: "Update Failed",
+ newVersionTitle: "New Version Available",
+ updateRunning: "Backing up and updating…",
+ updatePreparing: "Preparing the update…",
+ updateFailedBody:
+ "The update could not be completed. Check the status and try again.",
+ updateRestartNotice:
+ "RUNE may be temporarily unavailable while the console restarts.",
+ },
+ validation: {
+ usernameRule:
+ "Only Korean, lowercase English letters, and single spaces between words are allowed.",
+ },
+ /* DEMO ONLY. The mock server seeds English sample team names; these map
+ the known sample names to each language so the demo reads naturally
+ when toggled. This is NOT for real, customer-created team names — those
+ are user data and must render verbatim (see localizeTeamName). */
+ demoTeams: {
+ Platform: "Platform",
+ Payments: "Payments",
+ Infra: "Infra",
+ Data: "Data",
+ Growth: "Growth",
+ },
+};
+
+export type TLocale = typeof en;
diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts
new file mode 100644
index 0000000..8f96920
--- /dev/null
+++ b/frontend/src/locales/index.ts
@@ -0,0 +1,84 @@
+// Bilingual locale system — i18next drives the language LIFECYCLE (init,
+// changeLanguage, the languageChanged event LocaleProvider re-renders on),
+// while the two static, compile-time-typed tables (en.ts / ko.ts) remain the
+// string source. `L` is a Proxy over the active table, so all `L.x.y` call
+// sites and the type safety (ko.ts satisfies `typeof en`) stay unchanged, and
+// switching now re-renders in place instead of reloading — no switch flash.
+
+import i18n from "i18next";
+
+import { en, type TLocale } from "./en";
+import { ko } from "./ko";
+
+/** LANG_STORAGE_KEY persists an explicit user language choice; absent, the
+ * browser language decides (Korean browsers → ko, everything else → en). */
+export const LANG_STORAGE_KEY = "rc_lang";
+
+export type TLanguage = "en" | "ko";
+
+const locales: Record = { en, ko };
+
+const detectLanguage = (): TLanguage => {
+ try {
+ const stored = localStorage.getItem(LANG_STORAGE_KEY);
+ if (stored === "en" || stored === "ko") return stored;
+ } catch {
+ /* storage unavailable (private mode) — fall through to browser language */
+ }
+ return navigator.language?.toLowerCase().startsWith("ko") ? "ko" : "en";
+};
+
+// Initialize i18next once. Resources are minimal — copy comes from the typed
+// tables via `L`, not i18next's t(); i18next is the language state machine +
+// change-event source that LocaleProvider subscribes to.
+if (!i18n.isInitialized) {
+ void i18n.init({
+ lng: detectLanguage(),
+ fallbackLng: "en",
+ resources: { en: { translation: {} }, ko: { translation: {} } },
+ interpolation: { escapeValue: false },
+ });
+}
+
+export { i18n };
+
+/** getLanguage returns the currently active language. Reactive callers read
+ * this inside render so a re-render (see LocaleProvider) picks up a change. */
+export const getLanguage = (): TLanguage =>
+ (i18n.language as TLanguage) === "ko" ? "ko" : "en";
+
+/** language is the language at module-eval time — kept for load-time-only
+ * consumers. Prefer getLanguage() in anything that must react to a switch. */
+export const language: TLanguage = getLanguage();
+
+/** L is the active locale table, resolved per access against the current
+ * language, so a re-render after a switch shows the new copy. */
+export const L: TLocale = new Proxy({} as TLocale, {
+ get(_t, prop: string | symbol) {
+ return locales[getLanguage()][prop as keyof TLocale];
+ },
+ has(_t, prop) {
+ return prop in locales[getLanguage()];
+ },
+ ownKeys() {
+ return Reflect.ownKeys(locales[getLanguage()]);
+ },
+ getOwnPropertyDescriptor(_t, prop) {
+ return Reflect.getOwnPropertyDescriptor(
+ locales[getLanguage()],
+ prop as keyof TLocale,
+ );
+ },
+});
+
+/** setLanguage persists the choice and switches i18next in place — no reload,
+ * so the UI swaps languages without the reload flash (LocaleProvider re-renders
+ * the tree on i18next's languageChanged event). */
+export const setLanguage = (lang: TLanguage) => {
+ try {
+ localStorage.setItem(LANG_STORAGE_KEY, lang);
+ } catch {
+ /* storage unavailable — the in-memory switch below still applies it */
+ }
+ void i18n.changeLanguage(lang);
+};
diff --git a/frontend/src/locales/ko.ts b/frontend/src/locales/ko.ts
new file mode 100644
index 0000000..e90371c
--- /dev/null
+++ b/frontend/src/locales/ko.ts
@@ -0,0 +1,387 @@
+import type { TLocale } from "./en";
+
+/**
+ * Korean UI copy — the strings the console shipped with, verbatim. Typed as
+ * TLocale (derived from en.ts) so any key drift between the languages is a
+ * compile error.
+ */
+export const ko: TLocale = {
+ btn: {
+ // Generic actions
+ close: "닫기",
+ cancel: "취소",
+ confirm: "확인",
+ save: "저장",
+ delete: "삭제하기",
+ remove: "제거하기",
+ add: "추가",
+ create: "생성",
+ change: "변경하기",
+ refresh: "새로고침",
+ retry: "다시 시도",
+ update: "업데이트",
+ later: "나중에",
+ // Auth / navigation
+ signOut: "로그아웃",
+ getStarted: "시작하기",
+ login: "로그인하기",
+ home: "홈으로",
+ // Workspace
+ restart: "재실행",
+ stop: "중지",
+ deactivate: "비활성화",
+ recreate: "삭제 후 재생성",
+ reconnect: "재연결",
+ // Teams
+ createTeam: "새 팀 만들기",
+ createGroup: "팀 생성하기",
+ rename: "이름 변경",
+ deleteTeam: "팀 삭제하기",
+ addMember: "멤버 추가하기",
+ addTeam: "팀 추가하기",
+ // Members / invitations
+ invite: "초대하기",
+ inviteMember: "멤버 초대하기",
+ sendInvitation: "초대 전송",
+ resendInvitationCode: "초대 코드 재전송",
+ cancelInvitation: "초대 취소",
+ cancelAction: "취소하기",
+ addTeamRole: "+ 팀/권한 추가",
+ removeRow: "✕",
+ updateChanges: "변경사항 업데이트",
+ resetChanges: "변경사항 초기화",
+ deactivateSession: "세션 비활성화",
+ deleteMember: "멤버 삭제",
+ },
+ modal: {
+ // Workspace
+ workspaceManage: "워크스페이스 관리",
+ workspaceDelete: "워크스페이스 삭제",
+ workspaceOrphaned: "워크스페이스 재생성 필요",
+ workspaceReconnect: "워크스페이스 재연결 필요",
+ // Teams
+ createTeam: "새 팀 만들기",
+ renameTeam: "팀 이름 변경",
+ deleteTeam: (teamName: string) => `팀 삭제 — ${teamName}`,
+ addMember: (teamName: string) => `멤버 추가 — ${teamName}`,
+ batchFailure: "일부 항목을 처리하지 못했습니다",
+ // Members / roles / invitations
+ roleChange: "권한 변경",
+ removeMembership: "멤버십 제거",
+ inviteMember: "멤버 초대",
+ cancelInvitation: "초대 취소",
+ deactivateSession: "세션 비활성화",
+ deleteMemberSingle: (account: string) => `멤버 삭제 — ${account}`,
+ deleteMemberBulk: (count: number) => `멤버 삭제 (${count}명)`,
+ },
+ nav: {
+ teams: "팀 관리",
+ users: "멤버 관리",
+ sessions: "세션 관리",
+ },
+ login: {
+ failed1: "로그인 중 문제가 발생했습니다.",
+ failed2: "다시 시도해 주세요.",
+ accountRequired: "콘솔을 사용하기 위해 RUNE 계정이 필요합니다.",
+ },
+ status: {
+ member: {
+ online: "온라인",
+ offline: "오프라인",
+ },
+ invitation: {
+ pending: "초대 수락 대기",
+ expired: "초대 코드 만료",
+ redeemed: "초대 코드 사용됨",
+ },
+ workspace: {
+ provisioning: "생성 중",
+ running: "실행 중",
+ stopping: "정지 중",
+ stopped: "정지",
+ starting: "재실행 중",
+ deleting: "삭제 중",
+ error: "사용 불가",
+ },
+ },
+ profile: {
+ menu: "프로필 메뉴",
+ image: "프로필 이미지",
+ plan: "플랜: Free",
+ },
+ common: {
+ none: "없음",
+ loading: "불러오는 중…",
+ team: "팀",
+ role: "권한",
+ memberName: "멤버 이름",
+ memberStatus: "멤버 상태",
+ selectAll: "전체 선택",
+ select: "선택",
+ all: "전체",
+ users: "사용자",
+ sort: "정렬",
+ sortBy: "정렬 기준",
+ noResults: "검색 결과가 없습니다.",
+ refreshRetry: "새로고침 후 다시 시도해 주세요.",
+ tryAgainLater: "잠시 후 다시 시도해 주세요.",
+ processFailed: "처리에 실패했습니다. 다시 시도해 주세요.",
+ selectName: (name: string) => `${name} 선택`,
+ },
+ elements: {
+ pagination: "페이지네이션",
+ prevPage: "이전 페이지",
+ nextPage: "다음 페이지",
+ search: "검색",
+ clear: "지우기",
+ clickToDismiss: "클릭하여 닫기",
+ unsavedChanges: "변경사항 있음",
+ },
+ auth: {
+ ownerLockedTitle: "사용할 수 없는 계정",
+ // 소유자 강조 요소를 사이에 둔 문장 분할: prefix + owner + suffix.
+ ownerLockedPrefix: "이 콘솔은 ",
+ ownerLockedSuffix: "이(가) 관리하고 있어요.",
+ ownerFallback: "다른 계정",
+ ownerLockedBody: "이 계정으로는 콘솔을 사용할 수 없습니다.",
+ },
+ teams: {
+ // Add-member modal
+ emailLabel: "이메일 (account)",
+ invalidEmail: "올바른 이메일 형식이 아닙니다.",
+ usernameLabel: "사용자 이름 (username)",
+ usernamePlaceholder: "사용자 이름",
+ roleLabel: "권한 (role)",
+ selectRole: "권한 선택",
+ // 인라인 멤버 상태 칩을 사이에 둔 문장 분할: prefix + status + suffix.
+ invitePrefix: "초대받은 사용자가 RUNE을 연결하면 ",
+ inviteSuffix: "으로 전환됩니다.",
+ // Team name / create / rename
+ teamNameRule: "숫자·한글·영어와 - _ 만 사용할 수 있습니다.",
+ teamName: "팀 이름",
+ teamNamePlaceholder: "예: platform-team",
+ parentTeamOptional: "상위 팀 (선택)",
+ selectTeam: "팀 선택",
+ parentCopyInfo1:
+ "상위 팀을 선택하면 상위 팀의 멤버가 새 팀에 자동 복사됩니다.",
+ parentCopyInfo2: "멤버 편집은 팀 생성 후 상세 페이지에서 할 수 있습니다.",
+ dupName: "같은 상위 팀에 동일한 이름이 이미 있습니다.",
+ invalidTeamName: "팀 이름 형식이 올바르지 않습니다.",
+ // Delete-team modal
+ hasChildrenAlert1: "하위 팀이 있는 팀은 삭제할 수 없습니다.",
+ hasChildrenAlert2: "하위 팀을 먼저 삭제한 후 다시 시도해 주세요.",
+ memoryChoiceInfo: "삭제하려는 팀의 기억 처리 방식을 선택해 주세요.",
+ transferOption: "① 다른 팀으로 이전",
+ defaultTag: "(기본값)",
+ destinationTeam: "이전받을 팀",
+ confirmTargetLabel: "확인 - 타겟 팀명 입력",
+ selectTeamFirst: "팀을 먼저 선택하세요",
+ targetNameMismatch: "타겟 팀명이 일치하지 않습니다.",
+ purgeOption: "② 팀 내 기억 삭제",
+ purgeDesc:
+ "다른 팀과 공유 중인 기억은 해당 팀에서 계속 조회할 수 있습니다.",
+ confirmDeleteLabel: "확인 - 삭제할 팀명 입력",
+ nameMismatch: "팀명이 일치하지 않습니다.",
+ cannotDeleteHasChildren: "하위 팀이 있어 삭제할 수 없습니다.",
+ // Org chart
+ viewDetails: (name: string) => `${name} 상세 보기`,
+ zoomOut: "축소",
+ zoomIn: "확대",
+ // Remove-membership / role-change modals
+ removeIntro: "다음 멤버십을 제거합니다:",
+ removeKeepSubteams:
+ "하위 팀 소속은 유지됩니다. 필요할 경우 개별 선택 후 제거하세요.",
+ roleChangeIntro: "다음 멤버의 권한을 변경합니다:",
+ roleChangeCol: "권한 변경",
+ // Tree & detail view
+ teamTree: "팀 트리",
+ teamMeta: (parent: string, children: string, count: number, date: string) =>
+ `상위 팀: ${parent} | 하위 팀: ${children} | 멤버: ${count}명 | 생성일: ${date}`,
+ countItems: (count: number) => `${count}개`,
+ membersHeading: (total: number) => `멤버 (${total})`,
+ memberPageInfo: (total: number, pageSize: number) =>
+ `총 ${total}명 · ${pageSize}명/페이지`,
+ roleHeader: "역할",
+ joinedAt: "합류일",
+ membersLoadError: "멤버 목록을 불러올 수 없습니다.",
+ noMembers: "멤버가 없습니다.",
+ changesSaved: "변경사항이 저장되었습니다.",
+ // Notices (team CRUD / member ops)
+ createTeamTitle: "팀 생성",
+ teamCreated: "팀이 생성되었습니다.",
+ createTeamFailed: "팀 생성에 실패했습니다.",
+ teamRenamed: "팀 이름이 변경되었습니다.",
+ renameFailed: "이름 변경에 실패했습니다.",
+ deleteTeamTitle: "팀 삭제",
+ teamDeleted: "팀이 삭제되었습니다.",
+ deleteFailed: "팀 삭제에 실패했습니다.",
+ addMemberTitle: "멤버 추가",
+ memberAdded: "멤버를 추가했습니다.",
+ addMemberFailed: "멤버 추가에 실패했습니다.",
+ roleChangeFailed: "권한 변경에 실패했습니다.",
+ membershipsRemoved: "멤버십이 제거되었습니다.",
+ removeFailed: "멤버십 제거에 실패했습니다.",
+ // Server error reasons
+ userNotFound: "사용자를 찾을 수 없습니다",
+ notTeamMember: "팀 멤버가 아닙니다",
+ alreadyInvited: "이미 초대된 사용자입니다.",
+ notRegistered: "등록되지 않은 계정입니다.",
+ cannotAddAdmin: "콘솔 관리자 계정은 추가할 수 없습니다.",
+ inviteSendFailed: "초대 코드 전송에 실패했습니다. 다시 시도해 주세요.",
+ // Team tree / nodes
+ checkTeamName: "팀 이름을 다시 확인해 주세요.",
+ collapseName: (name: string) => `${name} 접기`,
+ expandName: (name: string) => `${name} 펼치기`,
+ // Teams page
+ switchView: "보기 전환",
+ treeDetail: "트리·상세",
+ orgChartView: "조직도",
+ searchTeams: "팀 검색",
+ teamsLoadError: "팀 정보를 불러올 수 없습니다.",
+ emptyTitle: "새로운 팀을 만들어 주세요.",
+ emptyDesc: "팀을 생성하면 멤버와 기억(memory)을 관리할 수 있습니다.",
+ },
+ members: {
+ // Invite modal (SC-12)
+ emailFormatError: "올바른 이메일 형식이 아닙니다",
+ duplicateAccount:
+ "이미 등록된 계정입니다. 멤버 추가 또는 초대 코드 재전송을 사용하세요.",
+ sendFailed: "초대 전송에 실패했습니다. 다시 시도해 주세요.",
+ teamRole: "팀 / 권한",
+ selectTeam: "팀 선택",
+ selectRole: "권한 선택",
+ setTeamAria: (n: number) => `세트 ${n} 팀`,
+ setRoleAria: (n: number) => `세트 ${n} role`,
+ subteamPreview: "하위 팀 권한 미리보기",
+ reason: "사유",
+ // 인라인 멤버 상태 칩을 사이에 둔 문장 분할: prefix + status + suffix.
+ invitePrefix:
+ "초대 시 사용자에게 초대 코드가 발송되어 사용자가 24시간 내 RUNE을 연결하면 ",
+ inviteSuffix: " 으로 전환됩니다.",
+ inviteExpiry:
+ "미연결 시 코드가 만료되고 초대 코드 재전송으로 사용자를 다시 초대할 수 있습니다.",
+ // Invite preview rows (invitePreview.ts)
+ directlyAssigned: "직접 지정",
+ teamAlreadyInvited: "이미 초대된 팀입니다.",
+ subteamOf: (teamName: string) => `${teamName} 하위 팀`,
+ // Cancel-invitation modal (D15)
+ cancelInviteExpires: (account: string) =>
+ `${account}의 미사용 초대 코드가 모두 만료됩니다.`,
+ userNotDeleted: "유저는 삭제되지 않습니다.",
+ // Session-deactivate modal (D12)
+ deactivateConfirm: (account: string) =>
+ `${account}의 세션을 비활성화하시겠습니까?`,
+ allMcpTerminated: "모든 MCP 세션이 종료됩니다.",
+ // Member-delete modal (SC-15)
+ deleteFailed: "멤버 삭제에 실패했습니다. 다시 시도해 주세요.",
+ noTeams: "소속된 팀이 없습니다.",
+ deleteSingleIntro: (account: string) =>
+ `${account} 계정을 삭제하며, 아래 팀에서 제거됩니다:`,
+ deleteBulkIntro: "다음 멤버의 계정을 삭제하며, 아래 팀에서 제거됩니다:",
+ // Membership-remove / role-change modals
+ removeFailed: "멤버십 제거에 실패했습니다. 다시 시도해 주세요.",
+ roleChangeFailedRetry: "권한 변경에 실패했습니다. 다시 시도해 주세요.",
+ roleChanged: "권한이 변경되었습니다.",
+ // Member-detail drawer (SC-13)
+ lastAccessed: (date: string) => `최근 접속 ${date}`,
+ redeemedAwaiting: "초대 코드 사용됨 · 연결 대기 중",
+ lastInviteSentAt: (datetime: string) => `최근 초대 코드 발송 ${datetime}`,
+ teamNotFound: "팀을 찾을 수 없습니다",
+ inviteCodeResent: "초대 코드를 재전송했습니다.",
+ resendCodeFailed: "초대 코드 재전송에 실패했습니다. 다시 시도해 주세요.",
+ addTeamTitle: "팀 추가",
+ addedToTeam: "팀에 추가되었습니다.",
+ alreadyTeamMember: "이미 소속된 팀입니다.",
+ addTeamFailed: "팀 추가에 실패했습니다. 다시 시도해 주세요.",
+ teamsHeading: (count: number) => `소속 팀 (${count})`,
+ selectAll: "전체선택",
+ noTeamsToAdd: "추가할 팀 없음",
+ teamToAdd: "추가할 팀",
+ roleToAdd: "추가할 role",
+ sessionDeactivated: "세션을 비활성화했습니다.",
+ sessionAlreadyExpired: "이미 만료된 세션입니다.",
+ deactivateFailed: "세션 비활성화에 실패했습니다. 다시 시도해 주세요.",
+ invitationCanceled: "초대를 취소했습니다.",
+ noInvitationToCancel: "취소할 초대가 없습니다.",
+ cancelInvitationFailed: "초대 취소에 실패했습니다. 다시 시도해 주세요.",
+ // Users page (SC-11)
+ lastInviteSent: "최근 초대 코드 발송",
+ resendFailedShort: "재전송 실패",
+ deleteMembersTitle: "멤버 삭제",
+ membersDeleted: "멤버를 삭제했습니다.",
+ membersLoadError: "멤버 정보를 불러올 수 없습니다.",
+ noMembersYet: "아직 초대한 멤버가 없습니다",
+ inviteHint: "멤버를 초대하면 초대 코드가 이메일로 발송됩니다",
+ searchByName: "이름 검색",
+ statusFilterAria: "status 필터",
+ teamFilterAria: "group 필터",
+ teamRoleHeader: "팀 (권한)",
+ // Sessions page (SC-16)
+ lastIssued: "최근 발급 시간",
+ lastAccessedAt: "최근 접속 시간",
+ historyLoadError: "이력 정보를 불러올 수 없습니다.",
+ sessionPageInfo: (total: number, pageSize: number) =>
+ `총 ${total}건 · ${pageSize}건/페이지`,
+ issuedAt: "발급 시간",
+ noHistory: "이력이 없습니다.",
+ },
+ workspace: {
+ // Navbar badge (SC-03 callout 2)
+ badgeLabel: "워크스페이스",
+ badgeRecreate: "재생성 필요",
+ badgeReconnect: "재연결 필요",
+ badgeNone: "워크스페이스 없음",
+ // Management modal (SC-02 state D + variants)
+ stopFailed: "워크스페이스 중지에 실패했습니다. 다시 시도해 주세요.",
+ restartFailed: "워크스페이스 재실행에 실패했습니다. 다시 시도해 주세요.",
+ recreateFailed: "워크스페이스 재생성에 실패했습니다. 다시 시도해 주세요.",
+ deleteFailed: "워크스페이스 삭제에 실패했습니다. 다시 시도해 주세요.",
+ deleteConfirm: "워크스페이스를 삭제하시겠습니까?",
+ deleteIrreversible: "삭제 후에는 되돌릴 수 없습니다.",
+ tearingDown1: "기존 워크스페이스를 삭제하는 중입니다…",
+ tearingDown2: "삭제가 완료되면 워크스페이스 생성을 시작합니다.",
+ orphaned1: "콘솔이 재설치되어 이 워크스페이스와 연결할 수 없습니다.",
+ orphaned2:
+ "기존에 저장된 데이터는 이전 보안 키로 암호화되어 복구할 수 없습니다.",
+ orphaned3: "삭제 후 재생성하면 빈 워크스페이스로 다시 시작합니다.",
+ reconnectExpired: "워크스페이스 연결이 만료되었습니다.",
+ reconnectPrompt: "재연결하여 데이터 플레인을 다시 활성화해 주세요.",
+ reconnectFailed: "재연결에 실패했습니다. 다시 시도해 주세요.",
+ loadFailed: "워크스페이스 정보를 불러올 수 없습니다.",
+ plan: "플랜",
+ statusLabel: "상태",
+ storedMemories: "저장된 기억 개수",
+ // Empty-workspace page (SC-02 states A/B/C)
+ pageAria: "워크스페이스 관리",
+ creatingTitle: "워크스페이스를 생성하는 중입니다…",
+ creatingDesc: "생성까지 약 3~5분 정도 소요됩니다.",
+ createFailedTitle: "워크스페이스 생성 실패",
+ createFailedDesc: "워크스페이스를 생성할 수 없습니다. 다시 시도해 주세요.",
+ createWorkspace: "워크스페이스 생성",
+ emptyTitle: "생성된 워크스페이스가 없습니다.",
+ emptyDesc: "워크스페이스를 생성하면 기억(memory)을 저장할 수 있습니다.",
+ // Console update floating card
+ updatingTitle: "콘솔을 업데이트하는 중입니다",
+ updateFailedTitle: "업데이트에 실패했습니다",
+ newVersionTitle: "새 버전이 출시되었습니다",
+ updateRunning: "백업 및 업데이트를 진행하고 있습니다…",
+ updatePreparing: "업데이트를 준비하고 있습니다…",
+ updateFailedBody:
+ "업데이트를 완료하지 못했습니다. 상태를 확인한 뒤 다시 시도해 주세요.",
+ updateRestartNotice:
+ "콘솔이 재시작되는 동안 RUNE 사용이 일시적으로 중단될 수 있습니다.",
+ },
+ validation: {
+ usernameRule: "한글, 영문 소문자, 단어 사이 공백 1칸만 입력할 수 있습니다.",
+ },
+ /* DEMO ONLY — see en.ts. Maps the mock's English sample team names to
+ Korean so the demo reads naturally when toggled; real team names pass
+ through verbatim (localizeTeamName). */
+ demoTeams: {
+ Platform: "플랫폼",
+ Payments: "결제",
+ Infra: "인프라",
+ Data: "데이터",
+ Growth: "그로스",
+ },
+};
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 444bff3..06a5e70 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { BrowserRouter } from "react-router";
import { createRoot } from "react-dom/client";
+import LocaleProvider from "@/components/LocaleProvider";
import QueryProvider from "@/state/tanstackQuery/QueryProvider";
import App from "@/App";
@@ -9,10 +10,12 @@ import "./index.css";
createRoot(document.getElementById("root")!).render(
-
-
-
-
-
+
+
+
+
+
+
+
,
);
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index 540cb66..8d9c943 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -12,6 +12,7 @@ import {
BTN_TEXT,
PATH_LIST,
} from "@/constants/commonConstants";
+import { L } from "@/locales";
/**
* LoginPage is the console sign-in screen (SC-01). Login is delegated to
@@ -56,9 +57,9 @@ const LoginPage = () => {
{failed && (
- 로그인 중 문제가 발생했습니다.
+ {L.login.failed1}
- 다시 시도해 주세요.
+ {L.login.failed2}
)}
@@ -72,7 +73,7 @@ const LoginPage = () => {
/>
{!failed && (
- 콘솔을 사용하기 위해 RUNE 계정이 필요합니다.
+ {L.login.accountRequired}
)}
diff --git a/frontend/src/pages/SessionsPage.tsx b/frontend/src/pages/SessionsPage.tsx
index 84097a9..53584e6 100644
--- a/frontend/src/pages/SessionsPage.tsx
+++ b/frontend/src/pages/SessionsPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useState } from "react";
import Button from "@/components/elements/Button";
import Dropdown from "@/components/elements/Dropdown";
@@ -6,15 +6,29 @@ import Feedback from "@/components/elements/Feedback";
import Pagination from "@/components/elements/Pagination";
import Table from "@/components/table/Table";
import TableCell from "@/components/table/TableCell";
+import TableEmptyRow from "@/components/table/TableEmptyRow";
import TableFoot from "@/components/table/TableFoot";
import TableHead from "@/components/table/TableHead";
import TableHeaderCell from "@/components/table/TableHeaderCell";
+import TableLoadingRow from "@/components/table/TableLoadingRow";
import TableRow from "@/components/table/TableRow";
import { useInvitationHistoryQuery } from "@/hooks/queries/useInvitationHistoryQuery";
+import {
+ useServerPagination,
+ useSyncPaginationTotal,
+} from "@/hooks/useServerPagination";
import { cn } from "@/utils/cn";
import { formatDateTime } from "@/utils/formatDate";
-import { BTN_TEXT } from "@/constants/commonConstants";
+import {
+ ARIA_LABELS,
+ BTN_TEXT,
+ DEFAULT_PAGE_SIZE,
+ FEEDBACK_TEXT,
+ PAGE_TITLES,
+ TABLE_HEADERS,
+} from "@/constants/commonConstants";
import type { TDropdownOption } from "@/types/commonTypes";
+import { L } from "@/locales";
const styles = {
page: "flex flex-col gap-3.5 p-4",
@@ -22,18 +36,16 @@ const styles = {
timeCell: "text-muted-foreground font-mono text-xs",
};
-/* Sort option set (SC-16 no.1) — values are the GET /invitations
- sort query params (console API design §6). No status filter or
- issuance button: issuance lives in user/team management. */
-const SORT_OPTIONS: TDropdownOption[] = [
- { value: "username", label: "멤버 이름" },
- { value: "issued_at", label: "최근 발급 시간" },
- { value: "last_access", label: "최근 접속 시간" },
+/* Sort option set (SC-16 no.1) — values are the GET /invitations sort query
+ params (console API design §6). Built per render (not at module scope): the
+ labels read L.* / getters, which a module-level array would freeze at the
+ load-time language, so the sort dropdown wouldn't follow a live switch. */
+const buildSortOptions = (): TDropdownOption[] => [
+ { value: "username", label: TABLE_HEADERS.memberName },
+ { value: "issued_at", label: L.members.lastIssued },
+ { value: "last_access", label: TABLE_HEADERS.lastAccess },
];
-/* 10 rows per page, fixed (SC-16 no.4) — the ?size=10 query param. */
-const PAGE_SIZE = 10;
-
/**
* SessionsPage is the session management screen (SC-16): the token
* issuance/access history table (state A) with a 3-way sort and fixed
@@ -43,46 +55,34 @@ const PAGE_SIZE = 10;
* (GET /invitations?view=history&sort&page&size).
*/
const SessionsPage = () => {
+ const sortOptions = buildSortOptions();
const [sort, setSort] = useState("last_access");
- const [page, setPage] = useState(1);
- const [totalPages, setTotalPages] = useState(1);
- const currentPage = Math.min(page, totalPages);
- const historyQuery = useInvitationHistoryQuery(sort, currentPage, PAGE_SIZE);
+ const { page, totalPages, setPage, resetPage, syncTotal } =
+ useServerPagination();
+ const historyQuery = useInvitationHistoryQuery(sort, page, DEFAULT_PAGE_SIZE);
const rows = historyQuery.data?.items ?? [];
const total = historyQuery.data?.total ?? 0;
-
- /* totalPages tracks the last response's total (a page/sort transition
- keeps the previous value via keepPreviousData until the new page
- resolves); currentPage clamps against it before the query call
- above, so the request itself is always in range. This effect only
- corrects the stored `page` once totalPages shrinks (e.g. a sort
- change reduces the result count), so Pagination and later renders
- resume from a valid value instead of the stale, too-high one. */
- useEffect(() => {
- const nextTotalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
- setTotalPages(nextTotalPages);
- if (page > nextTotalPages) setPage(nextTotalPages);
- }, [total, page]);
+ useSyncPaginationTotal(syncTotal, total);
/* Sort change resets to page 1 (SC-16 no.4). */
const changeSort = (value: string) => {
setSort(value);
- setPage(1);
+ resetPage();
};
/* ── SC-16 state B — 조회 실패 ──────────────────────────────────── */
if (historyQuery.isError) {
return (
-
+
{
/* ── SC-16 state A — 기본 ───────────────────────────────────────── */
return (
-
+
{
scrollClassName="min-h-[394px]"
toolbar={
- 정렬 기준
+ {L.common.sortBy}
}
foot={
@@ -135,30 +135,20 @@ const SessionsPage = () => {
{/* Fixed column widths — auto layout would resize per page's
content and shift the headers while paginating. */}
- 사용자
- 발급 시간
- 최근 접속 시간
+
+ {TABLE_HEADERS.user}
+
+
+ {TABLE_HEADERS.issuedAt}
+
+
+ {TABLE_HEADERS.lastAccess}
+
- {historyQuery.isPending && (
-
-
- 불러오는 중…
-
-
- )}
+ {historyQuery.isPending && }
{!historyQuery.isPending && total === 0 && (
-
-
- 이력이 없습니다.
-
-
+ {L.members.noHistory}
)}
{rows.map((row) => (
/* Reissues are separate rows (D11) — username alone is not
diff --git a/frontend/src/pages/TeamsPage.tsx b/frontend/src/pages/TeamsPage.tsx
index 7ed5b80..aa7bead 100644
--- a/frontend/src/pages/TeamsPage.tsx
+++ b/frontend/src/pages/TeamsPage.tsx
@@ -7,18 +7,15 @@ import SearchInput from "@/components/elements/SearchInput";
import CreateTeamModal from "@/components/teams/CreateTeamModal";
import OrgChart from "@/components/teams/OrgChart";
import TreeDetailView from "@/components/teams/TreeDetailView";
-import { useCreateTeamMutation } from "@/hooks/mutations/useTeamMutations";
import { useTeamsTreeQuery } from "@/hooks/queries/useTeamsTreeQuery";
-import { parseErrorCode } from "@/api/parseError";
+import { useTeamCrud } from "@/hooks/useTeamCrud";
import { cn } from "@/utils/cn";
-import { BTN_TEXT } from "@/constants/commonConstants";
-import { useNoticeStore } from "@/stores/noticeStore";
-
-/** Create-team error codes → SC-07 copy (shared with TreeDetailView). */
-const CREATE_TEAM_REASON: Record = {
- TEAM_NAME_DUPLICATE: "같은 상위 팀에 동일한 이름이 이미 있습니다.",
- TEAM_NAME_INVALID: "팀 이름 형식이 올바르지 않습니다.",
-};
+import {
+ BTN_TEXT,
+ FEEDBACK_TEXT,
+ PAGE_TITLES,
+} from "@/constants/commonConstants";
+import { L } from "@/locales";
const feedbackPanel =
"m-6 flex min-h-[340px] flex-col items-center justify-center gap-3 text-center";
@@ -57,28 +54,17 @@ const TeamsPage = () => {
/* SC-06 state B (팀 0개) create action — the tree panel's [새 팀 만들기]
is gone when there are no teams, so the empty panel owns the create
- flow (same mutation/error mapping as TreeDetailView's SC-07). */
+ flow (same mutation/error mapping as TreeDetailView's SC-07, via the
+ shared useTeamCrud hook). */
const [createOpen, setCreateOpen] = useState(false);
- const [createError, setCreateError] = useState(null);
- const createTeam = useCreateTeamMutation();
- const showNotice = useNoticeStore((s) => s.showNotice);
-
- const handleCreate = (name: string, parentId: string | null) => {
- setCreateError(null);
- createTeam.mutate(
- { name, parentId },
- {
- onSuccess: () => {
- setCreateOpen(false);
- showNotice("팀 생성", "팀이 생성되었습니다.", "success");
- },
- onError: async (res) => {
- const code = await parseErrorCode(res);
- setCreateError(CREATE_TEAM_REASON[code] ?? "팀 생성에 실패했습니다.");
- },
- },
- );
- };
+ const {
+ teamError: createError,
+ clearTeamError,
+ handleCreate,
+ } = useTeamCrud({
+ teamId: "",
+ onDone: () => setCreateOpen(false),
+ });
/* 트리·상세 is the entry view (its first top-level team auto-selected);
조직도 is reached by the view toggle. */
@@ -120,18 +106,18 @@ const TeamsPage = () => {
if (isPending) {
return (
-
+
);
}
if (isError) {
return (
-
+
{
}
return (
-
+
-
+
{
aria-pressed={view === "tree"}
onClick={() => setView("tree")}
>
- 트리·상세
+ {L.teams.treeDetail}
{
aria-pressed={view === "org"}
onClick={() => setView("org")}
>
- 조직도
+ {L.teams.orgChartView}
{/* Nothing to search when there are no teams (SC-06 state B). */}
@@ -177,7 +167,7 @@ const TeamsPage = () => {
@@ -187,8 +177,8 @@ const TeamsPage = () => {
{teams.length === 0 ? (
{
btnColor="mintFilled"
className="w-fit"
handleClick={() => {
- setCreateError(null);
+ clearTeamError();
setCreateOpen(true);
}}
/>
diff --git a/frontend/src/pages/UITestPage.tsx b/frontend/src/pages/UITestPage.tsx
index 6b4af32..6b80250 100644
--- a/frontend/src/pages/UITestPage.tsx
+++ b/frontend/src/pages/UITestPage.tsx
@@ -1,6 +1,5 @@
import { Fragment, useEffect, useState } from "react";
-import MembershipRow from "@/components/drawer/MembershipRow";
import Badge from "@/components/elements/Badge";
import Button from "@/components/elements/Button";
import Checkbox from "@/components/elements/Checkbox";
@@ -26,6 +25,8 @@ import TableRow from "@/components/table/TableRow";
import TableToolbar from "@/components/table/TableToolbar";
import TeamTree from "@/components/tree/TeamTree";
import TeamTreeFooter from "@/components/tree/TeamTreeFooter";
+import MembershipRow from "@/components/users/MembershipRow";
+import { useToastStore } from "@/state/store/toastStore";
import { cn } from "@/utils/cn";
import { BTN_TEXT } from "@/constants/commonConstants";
import {
@@ -34,14 +35,11 @@ import {
MEMBER_STATUS_VAR,
WORKSPACE_STATUS_VAR,
} from "@/constants/styleConstants";
-import type {
- TMemberStatus,
- TTeamNode,
- TWorkspaceStatus,
-} from "@/types/commonTypes";
+import { ROLE_OPTIONS } from "@/constants/teamConstants";
+import type { TMemberStatus } from "@/types/commonTypes";
import type { TBTNColor } from "@/types/styleTypes";
-import type { TInvitationStatus } from "@/types/teamTypes";
-import { useToastStore } from "@/stores/toastStore";
+import type { TInvitationStatus, TTeamViewNode } from "@/types/teamTypes";
+import type { TWorkspaceStatus } from "@/types/workspaceTypes";
type TUITestModal = "alert" | "confirm" | "wide" | "scroll" | null;
@@ -56,12 +54,6 @@ const BTN_THEMES: { color: TBTNColor; role: string; text: string }[] = [
{ color: "redOutline", role: "outline · danger", text: "멤버 삭제" },
];
-const ROLE_OPTIONS = [
- { value: "edit", label: "edit" },
- { value: "write", label: "write" },
- { value: "read", label: "read" },
-];
-
const TEAM_OPTIONS = [
{ value: "platform", label: "플랫폼팀" },
{ value: "fe", label: "프론트엔드", depth: 1 },
@@ -119,7 +111,7 @@ const SESSION_ROWS = [
{ account: "a@corp.com", issuedAt: "2026-07-05 18:20", connectedAt: "" },
];
-const TEAM_FIXTURE: TTeamNode[] = [
+const TEAM_FIXTURE: TTeamViewNode[] = [
{
id: "platform",
name: "Platform",
@@ -207,7 +199,9 @@ const UITestPage = () => {
const [tableSearch, setTableSearch] = useState("");
const [tablePage, setTablePage] = useState(1);
const [treeQuery, setTreeQuery] = useState("");
- const [treeSelected, setTreeSelected] = useState(TEAM_FIXTURE[0]);
+ const [treeSelected, setTreeSelected] = useState(
+ TEAM_FIXTURE[0],
+ );
const [drawerOpen, setDrawerOpen] = useState(false);
const [memberships, setMemberships] = useState(
MEMBERSHIP_FIXTURE.map((m) => ({ ...m, role: m.baseRole })),
diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx
index 0801305..4869bea 100644
--- a/frontend/src/pages/UsersPage.tsx
+++ b/frontend/src/pages/UsersPage.tsx
@@ -1,30 +1,22 @@
-import { useEffect, useState } from "react";
+import { useState } from "react";
import Button from "@/components/elements/Button";
import Checkbox from "@/components/elements/Checkbox";
-import Dropdown from "@/components/elements/Dropdown";
import Feedback from "@/components/elements/Feedback";
-import MemberStatus from "@/components/elements/MemberStatus";
import Pagination from "@/components/elements/Pagination";
-import SearchInput from "@/components/elements/SearchInput";
import Table from "@/components/table/Table";
-import TableCell from "@/components/table/TableCell";
+import TableEmptyRow from "@/components/table/TableEmptyRow";
import TableFoot from "@/components/table/TableFoot";
import TableHead from "@/components/table/TableHead";
import TableHeaderCell from "@/components/table/TableHeaderCell";
-import TableRow from "@/components/table/TableRow";
+import TableLoadingRow from "@/components/table/TableLoadingRow";
import MemberBatchFailureModal from "@/components/teams/MemberBatchFailureModal";
-import { buildTeamOptions } from "@/components/teams/teamOptions";
import InviteMemberModal from "@/components/users/InviteMemberModal";
import MemberDeleteModal from "@/components/users/MemberDeleteModal";
import MemberDetailDrawer from "@/components/users/MemberDetailDrawer";
-import { CHIP_STATUS } from "@/components/users/memberStatusMap";
-import {
- useCancelInvitation,
- useDeleteUsers,
- useInviteMutation,
- useResendInvitation,
-} from "@/hooks/mutations/useInvitationMutations";
+import UserRow from "@/components/users/UserRow";
+import UsersToolbar from "@/components/users/UsersToolbar";
+import { useCancelInvitation } from "@/hooks/mutations/useInvitationMutations";
import {
useAddUserMembership,
useBulkUserRoleChange,
@@ -35,91 +27,75 @@ import { useTeamsTreeQuery } from "@/hooks/queries/useTeamsTreeQuery";
import { useUserQuery } from "@/hooks/queries/useUserQuery";
import { useUsersQuery } from "@/hooks/queries/useUsersQuery";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
-import { parseErrorCode } from "@/api/parseError";
-import { BTN_TEXT } from "@/constants/commonConstants";
+import { usePageScopedSelection } from "@/hooks/usePageScopedSelection";
+import {
+ useServerPagination,
+ useSyncPaginationTotal,
+} from "@/hooks/useServerPagination";
+import { useUserBatchActions } from "@/hooks/useUserBatchActions";
+import { buildTeamOptions } from "@/utils/buildTeamOptions";
+import { SESSION_STATUS } from "@/constants/apiConstants";
+import {
+ ARIA_LABELS,
+ BTN_TEXT,
+ DEFAULT_PAGE_SIZE,
+ FEEDBACK_TEXT,
+ PAGE_TITLES,
+ TABLE_HEADERS,
+} from "@/constants/commonConstants";
import type { TDropdownOption } from "@/types/commonTypes";
import type { TTeamMemberRole, TTeamTree } from "@/types/teamTypes";
-import type {
- TInvitePayload,
- TInviteResult,
- TUserListItem,
-} from "@/types/userTypes";
-import { useNoticeStore } from "@/stores/noticeStore";
+import { L } from "@/locales";
const styles = {
page: "flex flex-col gap-3.5 p-4",
- /* Wide enough for typical names at the 40% column; anything longer
- (up to the 50-char username cap) truncates with an ellipsis and
- keeps the full name in the title tooltip. */
- usernameCell: "max-w-[400px] truncate",
- overflowChip:
- "border-border text-faint ml-1.5 rounded-full border px-2 text-xs",
};
-/* Filter/sort option sets (SC-11 no.2–3). "all" stands in for 전체. The list
- shows only the session axis, so the filter matches it. */
-const STATUS_OPTIONS: TDropdownOption[] = [
- { value: "all", label: "전체" },
- { value: "online", label: "온라인" },
- { value: "offline", label: "오프라인" },
+/* Filter/sort option sets (SC-11 no.2–3). "all" stands in for 전체. Built
+ inside the component (per render) rather than at module scope: the labels
+ read L.* / getters, which a module-level array would freeze at the
+ load-time language — the dropdowns would then not follow a live switch. */
+const buildStatusOptions = (): TDropdownOption[] => [
+ { value: "all", label: L.common.all },
+ { value: SESSION_STATUS.online, label: L.status.member.online },
+ { value: SESSION_STATUS.offline, label: L.status.member.offline },
];
/* Depth indent stripped — the 150px filter trigger can't fit deep-tree
indentation (it forces horizontal scrolling in the menu); teams list
- flush left in tree order and long names truncate with an ellipsis.
- Computed in-component (buildTeamOptions depends on the real teams
- query result — no static dummy list anymore). */
+ flush left in tree order and long names truncate with an ellipsis. */
const buildGroupOptions = (teams: TTeamTree): TDropdownOption[] => [
- { value: "all", label: "전체" },
+ { value: "all", label: L.common.all },
...buildTeamOptions(teams).map(({ value, label }) => ({ value, label })),
];
-const SORT_OPTIONS: TDropdownOption[] = [
- { value: "last_invited", label: "최근 초대 코드 발송" },
- { value: "username", label: "멤버 이름" },
+const buildSortOptions = (): TDropdownOption[] => [
+ { value: "last_invited", label: L.members.lastInviteSent },
+ { value: "username", label: TABLE_HEADERS.memberName },
];
-/** First membership as "team · role"; the rest collapse into "+n". */
-const membershipSummary = (user: TUserListItem) => {
- const [first, ...rest] = user.memberships;
- return first
- ? { summary: `${first.teamName} · ${first.role}`, extra: rest.length }
- : { summary: "—", extra: 0 };
-};
-
-/* 10 rows per page — caps the table height inside one screen; also the
- ?size=10 GET /users query param. */
-const PAGE_SIZE = 10;
-
-/** Batch-delete failure reasons shown by account (DELETE /users). */
-const BATCH_REASON: Record = {
- USER_NOT_FOUND: "사용자를 찾을 수 없습니다",
-};
-
/**
* UsersPage is the user management screen (SC-11): cross-team user
* list with search/filters/sort, bulk actions, and pagination, plus
* the invite modal (SC-12), member detail drawer (SC-13), and delete
* confirm (SC-15). The list is driven by GET /users (useUsersQuery) —
* search/status/team/sort/page all become query params, and the
- * server returns the already filtered/sorted/paged rows. The drawer's
- * detail (GET /users/{id}), role/membership batch, session deactivate,
- * invite/resend/cancel, and delete mutations are all wired to the API.
+ * server returns the already filtered/sorted/paged rows. Bulk flows
+ * (invite/resend/delete) live in useUserBatchActions; the drawer's
+ * membership machine lives in useMembershipDrafts.
*/
const UsersPage = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [groupFilter, setGroupFilter] = useState("all");
const [sort, setSort] = useState("last_invited");
- const [selectedIds, setSelectedIds] = useState>(new Set());
- const [page, setPage] = useState(1);
const [inviteOpen, setInviteOpen] = useState(false);
const [drawerUserId, setDrawerUserId] = useState(null);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
- const [batchFailures, setBatchFailures] = useState<
- { account: string; reason: string }[] | null
- >(null);
- const showNotice = useNoticeStore((state) => state.showNotice);
+ const { selectedIds, toggleOne, toggleAll, clearSelection, setSelectedIds } =
+ usePageScopedSelection();
+ const { page, totalPages, setPage, resetPage, syncTotal } =
+ useServerPagination();
const { data: teams } = useTeamsTreeQuery();
const detailQuery = useUserQuery(drawerUserId ?? "");
@@ -127,11 +103,26 @@ const UsersPage = () => {
const removeMemberships = useRemoveUserMemberships(drawerUserId ?? "");
const addMembership = useAddUserMembership(drawerUserId ?? "");
const deactivateSession = useDeactivateUserSession(drawerUserId ?? "");
- const invite = useInviteMutation();
- const resend = useResendInvitation();
const cancel = useCancelInvitation();
- const deleteUsersMutation = useDeleteUsers();
const groupOptions = buildGroupOptions(teams ?? []);
+ const statusOptions = buildStatusOptions();
+ const sortOptions = buildSortOptions();
+
+ const {
+ inviteMember,
+ resendCode,
+ resendCodes,
+ deleteMembers,
+ batchFailures,
+ closeBatchFailures,
+ } = useUserBatchActions({
+ setSelectedIds,
+ onDeleted: (deletedIds) => {
+ if (drawerUserId && deletedIds.includes(drawerUserId)) {
+ setDrawerUserId(null);
+ }
+ },
+ });
const debouncedSearch = useDebouncedValue(search, 300);
const usersQuery = useUsersQuery({
@@ -140,17 +131,11 @@ const UsersPage = () => {
teamId: groupFilter,
sort,
page,
- size: PAGE_SIZE,
+ size: DEFAULT_PAGE_SIZE,
});
const users = usersQuery.data?.items ?? [];
const total = usersQuery.data?.total ?? 0;
- const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
- const currentPage = Math.min(page, totalPages);
-
- /* keep the requested page within range so the query never asks for an out-of-range page */
- useEffect(() => {
- if (page > totalPages) setPage(totalPages);
- }, [page, totalPages]);
+ useSyncPaginationTotal(syncTotal, total);
/* A search/filter is active whenever it would narrow the server-side
result — distinguishes "no members at all" (state B) from "no
@@ -170,124 +155,29 @@ const UsersPage = () => {
(setter: (value: T) => void) =>
(value: T) => {
setter(value);
- setPage(1);
- setSelectedIds(new Set());
+ resetPage();
+ clearSelection();
};
/* Moving to another page clears the selection too — checks are
page-scoped, and a checked row on the old page shouldn't ride along
into a bulk action taken on a different page. */
const goToPage = (next: number) => {
setPage(next);
- setSelectedIds(new Set());
+ clearSelection();
};
/* Select-all is page-scoped. */
const allSelected =
users.length > 0 && users.every((u) => selectedIds.has(u.userId));
- const toggleAll = (checked: boolean) =>
- setSelectedIds((prev) => {
- const next = new Set(prev);
- users.forEach((u) =>
- checked ? next.add(u.userId) : next.delete(u.userId),
- );
- return next;
- });
-
- const toggleOne = (userId: string, checked: boolean) =>
- setSelectedIds((prev) => {
- const next = new Set(prev);
- if (checked) next.add(userId);
- else next.delete(userId);
- return next;
- });
-
- /** POST /invitations — server judges duplicates and target states; only
- the staged team/role sets are sent (buildInvitePreview's sub-team
- expansion is display-only, the server performs the real expansion). */
- const inviteMember = async (
- payload: TInvitePayload,
- ): Promise => {
- try {
- await invite.mutateAsync({
- account: payload.email,
- username: payload.username,
- memberships: payload.sets.map((set) => ({
- teamId: set.teamId,
- role: set.role as TTeamMemberRole,
- })),
- });
- return "success";
- } catch (err) {
- if (err instanceof Response) {
- const code = await parseErrorCode(err);
- return code === "ALREADY_TEAM_MEMBER" ? "duplicate-account" : "error";
- }
- return "error";
- }
- };
-
- /** POST /invitations/resend (per target) — status never changes (D10).
- Selection stays intact on partial failure so the user can retry. */
- const resendCodes = async (targets: TUserListItem[]) => {
- const results = await Promise.allSettled(
- targets.map((u) => resend.mutateAsync(u.userId)),
- );
- const failed = targets.filter((_, i) => results[i].status === "rejected");
- if (failed.length === 0) {
- showNotice("초대 코드 재전송", "초대 코드를 재전송했습니다.", "info");
- return;
- }
- setBatchFailures(
- failed.map((u) => ({ account: u.account, reason: "재전송 실패" })),
- );
- };
-
- /** DELETE /users (batch) — memberships, session token, and unused
- invite codes go together (D13). Full success clears the targets
- from selection and closes the drawer if it pointed at one of
- them; partial failure shows the failure modal (account + reason)
- and leaves the still-failed ids selected for retry. Throws only
- on full failure, so MemberDeleteModal/the drawer's onDeleteMember
- contract (resolve unless every target failed) is unaffected. */
- const deleteMembers = async (targets: TUserListItem[]) => {
- const userIds = targets.map((u) => u.userId);
- const result = await deleteUsersMutation.mutateAsync(userIds);
- const failedIds = new Set(result.failed.map((f) => f.id));
- const succeededIds = userIds.filter((id) => !failedIds.has(id));
-
- setSelectedIds((prev) => {
- const next = new Set(prev);
- succeededIds.forEach((id) => next.delete(id));
- return next;
- });
- if (drawerUserId && succeededIds.includes(drawerUserId)) {
- setDrawerUserId(null);
- }
-
- if (result.failed.length === 0) {
- showNotice("멤버 삭제", "멤버를 삭제했습니다.", "info");
- return;
- }
- if (succeededIds.length === 0) {
- throw new Error("delete failed for every target");
- }
- setBatchFailures(
- result.failed.map((f) => ({
- account: targets.find((u) => u.userId === f.id)?.account ?? f.id,
- reason: BATCH_REASON[f.code] ?? f.code,
- })),
- );
- };
-
/* ── SC-11 state C — 조회 실패 ──────────────────────────────────── */
if (usersQuery.isError) {
return (
-
+
{
all hidden) ─── */
if (!usersQuery.isPending && total === 0 && !hasActiveFilter) {
return (
-
+
{
}
return (
-
+
{
pagination never shifts the layout. */
scrollClassName="min-h-[526px]"
toolbar={
-
-
-
-
- {/* filter/order dropdown */}
-
-
- 정렬 기준
-
-
-
- 멤버 상태
-
-
-
- 팀
-
-
-
-
-
- {/* Actions — second row, left-aligned (SC-11 no.4–6) */}
-
- resendCodes(selectedUsers)}
- />
- setBulkDeleteOpen(true)}
- />
- setInviteOpen(true)}
- />
-
-
-
+ resendCodes(selectedUsers)}
+ onOpenBulkDelete={() => setBulkDeleteOpen(true)}
+ onOpenInvite={() => setInviteOpen(true)}
+ />
}
foot={
@@ -435,77 +266,41 @@ const UsersPage = () => {
+ toggleAll(
+ users.map((u) => u.userId),
+ checked,
+ )
+ }
+ ariaLabel={ARIA_LABELS.selectAll}
/>
{/* Fixed column widths — auto layout would resize per page's
content and shift the headers while paginating. */}
- 멤버 이름
- 멤버 상태
- 팀 (권한)
+
+ {TABLE_HEADERS.memberName}
+
+
+ {TABLE_HEADERS.memberStatus}
+
+
+ {TABLE_HEADERS.teamWithRole}
+
- {usersQuery.isPending && (
-
-
- 불러오는 중…
-
-
- )}
+ {usersQuery.isPending && }
{!usersQuery.isPending && users.length === 0 && (
-
-
- 검색 결과가 없습니다.
-
-
+ {L.common.noResults}
)}
- {users.map((user) => {
- const { summary, extra } = membershipSummary(user);
- return (
- setDrawerUserId(user.userId)}
- >
- {/* Checkbox clicks must not open the drawer (SC-11 no.8) */}
-
- e.stopPropagation()}>
- toggleOne(user.userId, checked)}
- ariaLabel={`${user.account} 선택`}
- />
-
-
-
- {user.username}
-
-
-
-
-
- {summary}
- {extra > 0 && (
- `${m.teamName} · ${m.role}`)
- .join(", ")}
- >
- +{extra}
-
- )}
-
-
- );
- })}
+ {users.map((user) => (
+ toggleOne(user.userId, checked)}
+ onOpen={() => setDrawerUserId(user.userId)}
+ />
+ ))}
@@ -543,7 +338,7 @@ const UsersPage = () => {
await deactivateSession.mutateAsync();
}}
onResendCode={async () => {
- await resend.mutateAsync(drawerUser.userId);
+ await resendCode(drawerUser.userId);
}}
onCancelInvitation={async () => {
await cancel.mutateAsync(drawerUser.userId);
@@ -570,7 +365,7 @@ const UsersPage = () => {
{batchFailures && (
setBatchFailures(null)}
+ onClose={closeBatchFailures}
/>
)}
diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx
index 7108f89..fd9a330 100644
--- a/frontend/src/pages/WorkspacePage.tsx
+++ b/frontend/src/pages/WorkspacePage.tsx
@@ -8,8 +8,10 @@ import {
isTransitionalStatus,
useWorkspaceQuery,
} from "@/hooks/queries/useWorkspaceQuery";
-import { PATH_LIST } from "@/constants/commonConstants";
-import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { useWorkspaceStore } from "@/state/store/workspaceStore";
+import { WORKSPACE_STATUS } from "@/constants/apiConstants";
+import { PAGE_TITLES, PATH_LIST } from "@/constants/commonConstants";
+import { L } from "@/locales";
const panelClass =
"m-6 flex min-h-[340px] flex-col items-center justify-center gap-3 text-center";
@@ -41,7 +43,7 @@ const WorkspacePage = () => {
const [createdHere, setCreatedHere] = useState(false);
useEffect(() => {
- if (workspace?.status === "running" && createdHere) {
+ if (workspace?.status === WORKSPACE_STATUS.running && createdHere) {
setCreatedHere(false);
openModal();
}
@@ -62,7 +64,7 @@ const WorkspacePage = () => {
const exists = workspace != null;
const transitional = exists && isTransitionalStatus(workspace.status);
- if (isLoading) return ;
+ if (isLoading) return ;
/* A workspace exists → go to the console. The one exception is our own
create still provisioning: stay and keep the spinner until it runs. */
@@ -84,29 +86,29 @@ const WorkspacePage = () => {
transitional;
return (
-
+
{creating ? (
) : createMutation.isError ? (
) : (
)}
diff --git a/frontend/src/pages/__tests__/SessionsPage.test.tsx b/frontend/src/pages/__tests__/SessionsPage.test.tsx
index eca2dd3..9ba6d06 100644
--- a/frontend/src/pages/__tests__/SessionsPage.test.tsx
+++ b/frontend/src/pages/__tests__/SessionsPage.test.tsx
@@ -169,7 +169,9 @@ describe("SessionsPage", () => {
"page",
);
/* username asc — a@corp.com's row ("a 사용자") leads the first page. */
- expect(await screen.findByText(usernameOf("a@corp.com"))).toBeInTheDocument();
+ expect(
+ await screen.findByText(usernameOf("a@corp.com")),
+ ).toBeInTheDocument();
});
it("shows the fixed page-size footer", async () => {
diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx
index 0d2d464..67db614 100644
--- a/frontend/src/pages/__tests__/UsersPage.test.tsx
+++ b/frontend/src/pages/__tests__/UsersPage.test.tsx
@@ -8,9 +8,9 @@ import UsersPage from "@/pages/UsersPage";
import * as invitationAPIs from "@/api/invitationAPIs";
import * as teamAPIs from "@/api/teamAPIs";
import * as userAPIs from "@/api/userAPIs";
+import { useNoticeStore } from "@/state/store/noticeStore";
import { BTN_TEXT, MODAL_TITLES } from "@/constants/commonConstants";
import type { TUserListItem } from "@/types/userTypes";
-import { useNoticeStore } from "@/stores/noticeStore";
const jsonRes = (body: unknown) =>
({ ok: true, json: async () => body }) as unknown as Response;
@@ -182,7 +182,10 @@ describe("UsersPage", () => {
total: 12, // > PAGE_SIZE → a second page exists
page: 1,
size: 10,
- items: [user("u_1", "k@corp.com", "김철수"), user("u_2", "m@corp.com", "박미영")],
+ items: [
+ user("u_1", "k@corp.com", "김철수"),
+ user("u_2", "m@corp.com", "박미영"),
+ ],
});
const typer = userEvent.setup();
renderPage();
@@ -269,10 +272,7 @@ describe("UsersPage", () => {
screen.getByPlaceholderText("user@corp.com"),
"new@corp.com",
);
- await typer.type(
- screen.getByLabelText("사용자 이름 (username)"),
- "김신입",
- );
+ await typer.type(screen.getByLabelText("사용자 이름 (username)"), "김신입");
await typer.click(screen.getByRole("button", { name: "세트 1 팀" }));
await typer.click(screen.getByRole("option", { name: "백엔드" }));
await typer.click(screen.getByRole("button", { name: "세트 1 role" }));
diff --git a/frontend/src/pages/__tests__/WorkspacePage.test.tsx b/frontend/src/pages/__tests__/WorkspacePage.test.tsx
index 70a873f..0e0601f 100644
--- a/frontend/src/pages/__tests__/WorkspacePage.test.tsx
+++ b/frontend/src/pages/__tests__/WorkspacePage.test.tsx
@@ -3,8 +3,8 @@ import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import WorkspacePage from "@/pages/WorkspacePage";
-import type { TWorkspace } from "@/types/commonTypes";
-import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { useWorkspaceStore } from "@/state/store/workspaceStore";
+import type { TWorkspace } from "@/types/workspaceTypes";
/* Server state is mocked; the page renders only while no workspace exists
(query → null), which is exactly the post-teardown handoff situation. */
@@ -70,8 +70,6 @@ describe("WorkspacePage", () => {
expect(
screen.getByText(/워크스페이스를 생성하는 중입니다/),
).toBeInTheDocument();
- expect(
- screen.queryByText("생성된 워크스페이스가 없습니다."),
- ).toBeNull();
+ expect(screen.queryByText("생성된 워크스페이스가 없습니다.")).toBeNull();
});
});
diff --git a/frontend/src/pages/teamsDummyData.ts b/frontend/src/pages/teamsDummyData.ts
deleted file mode 100644
index 20c8324..0000000
--- a/frontend/src/pages/teamsDummyData.ts
+++ /dev/null
@@ -1,468 +0,0 @@
-import type { TTeamNode, TTeamTree } from "@/types/teamTypes";
-
-/**
- * Dummy fixtures shaped exactly like the console API wire format.
- * Placeholder data for TeamsPage until the BFF endpoints are live — do
- * not ship usages.
- *
- * Shapes covered:
- * - GET /teams/tree (Teams — flat nodes, client builds the tree)
- * - GET /users (Users — cross-team global list, paginated)
- * - GET /teams/{teamId}/members (Team members — per-team member table, paginated)
- */
-
-/** Invitation-code lifecycle status on the wire (common contract). */
-export type TApiInvitationStatus =
- "invite_pending" | "invite_expired" | "invite_redeemed";
-
-/** Session-token liveness on the wire (common contract). */
-export type TApiSessionStatus = "online" | "offline";
-
-/** Roles grantable to members (common contract — Admin is console-account only). */
-export type TApiMemberRole = "edit" | "write" | "read";
-
-/** One membership entry inside a GET /users item (Users). */
-export type TApiUserMembership = {
- teamId: string;
- teamName: string;
- role: TApiMemberRole;
-};
-
-/** One item of GET /users (Users). */
-export type TApiUser = {
- userId: string;
- account: string;
- invitationStatus: TApiInvitationStatus;
- sessionStatus: TApiSessionStatus;
- memberships: TApiUserMembership[];
- lastAccessAt: string | null;
- lastInvitedAt: string | null;
- sessionExpiredAt: string | null;
-};
-
-/** One item of GET /teams/{teamId}/members (Team members). */
-export type TApiTeamMember = {
- userId: string;
- account: string;
- role: TApiMemberRole;
- invitationStatus: TApiInvitationStatus;
- sessionStatus: TApiSessionStatus;
- joinedAt: string | null;
-};
-
-/** Common paginated envelope (common contract — { total, page, size, items }). */
-export type TApiPage = {
- total: number;
- page: number;
- size: number;
- items: T[];
-};
-
-/** Build a flat tree node — childCount always mirrors childrenIds. */
-const team = (
- id: string,
- name: string,
- parentId: string | null,
- childrenIds: string[],
- memberCount: number,
-): TTeamNode => ({
- id,
- name,
- parentId,
- childrenIds,
- childCount: childrenIds.length,
- memberCount,
-});
-
-/**
- * GET /teams/tree — 45 teams, 8 roots, up to 11 levels deep (load-test
- * sized: wide enough to overflow the org chart horizontally).
- * The original six (t_a~t_f) keep memberCounts consistent with
- * DUMMY_USERS; the load-test teams below are not reflected there.
- */
-export const DUMMY_TEAMS_TREE: TTeamTree = [
- // ── original fixture ──────────────────────────────────────────────
- team("t_a", "플랫폼", null, ["t_b", "t_c", "t_mob"], 2),
- team("t_b", "백엔드", "t_a", ["t_api", "t_wrk", "t_db"], 6),
- team("t_c", "프론트엔드", "t_a", ["t_con", "t_ds"], 2),
- team("t_d", "디자인", null, [], 2),
- team("t_e", "보안", null, ["t_f", "t_cmp", "t_iam"], 2),
- team("t_f", "볼트", "t_e", ["t_fhe", "t_key"], 1),
- // ── load-test teams ───────────────────────────────────────────────
- team("t_mob", "모바일", "t_a", ["t_ios", "t_and"], 4),
- // long name — exercises node truncation + title tooltip
- team("t_api", "API 게이트웨이 익스피리언스", "t_b", ["t_gw", "t_gql"], 5),
- team("t_wrk", "워커", "t_b", [], 3),
- team("t_db", "데이터베이스", "t_b", [], 2),
- team("t_gw", "게이트웨이", "t_api", ["t_edge"], 2),
- // ── deep chain under Gateway (vertical overflow test, depth 11) ────
- team("t_edge", "엣지", "t_gw", ["t_rte"], 2),
- team("t_rte", "라우팅", "t_edge", ["t_lb"], 1),
- team("t_lb", "로드밸런서", "t_rte", ["t_rl"], 2),
- team("t_rl", "속도 제한", "t_lb", ["t_cch"], 1),
- team("t_cch", "캐싱", "t_rl", ["t_cdn"], 2),
- team("t_cdn", "콘텐츠 전송", "t_cch", ["t_pop"], 3),
- team("t_pop", "서울 거점", "t_cdn", [], 2),
- team("t_gql", "쿼리 API", "t_api", [], 3),
- team("t_con", "콘솔", "t_c", [], 4),
- team("t_ds", "디자인 시스템", "t_c", [], 2),
- team("t_ios", "아이폰 앱", "t_mob", [], 2),
- team("t_and", "안드로이드 앱", "t_mob", [], 2),
- team("t_cmp", "컴플라이언스", "t_e", [], 3),
- team("t_iam", "접근 제어", "t_e", [], 2),
- team("t_fhe", "동형암호 코어", "t_f", [], 4),
- team("t_key", "키 관리", "t_f", [], 2),
- team("t_data", "데이터", null, ["t_ana", "t_ml"], 1),
- team("t_ana", "분석", "t_data", [], 3),
- team("t_ml", "머신러닝", "t_data", ["t_trn", "t_inf"], 2),
- team("t_trn", "학습", "t_ml", [], 3),
- team("t_inf", "추론", "t_ml", [], 2),
- team("t_infra", "인프라", null, ["t_sre", "t_net", "t_obs"], 2),
- team("t_sre", "사이트 신뢰성", "t_infra", [], 4),
- team("t_net", "네트워크", "t_infra", [], 2),
- team("t_obs", "관측성", "t_infra", [], 3),
- team("t_prod", "프로덕트", null, ["t_bil", "t_onb"], 5),
- team("t_bil", "결제", "t_prod", [], 2),
- team("t_onb", "온보딩", "t_prod", [], 1),
- team("t_gro", "그로스", null, ["t_mkt", "t_sal"], 1),
- team("t_mkt", "마케팅", "t_gro", [], 3),
- team("t_sal", "영업", "t_gro", [], 2),
- team("t_cs", "고객 성공", null, ["t_sup", "t_edu"], 2),
- team("t_sup", "지원", "t_cs", [], 3),
- team("t_edu", "교육", "t_cs", [], 2),
-];
-
-/**
- * GET /users?page=1&size=10 — 23 users over 3 pages, covering all
- * invitation/session status combinations. Per-status timestamp display
- * (Users): online → lastAccessAt / pending·expired → lastInvitedAt /
- * offline-after-redeemed → sessionExpiredAt. u_9~u_11 are also team B
- * members (mirrored in DUMMY_TEAM_B_MEMBERS); u_16 exercises the "+n"
- * membership overflow chip and u_17 the long-account truncation.
- */
-export const DUMMY_USERS: TApiPage = {
- total: 23,
- page: 1,
- size: 10,
- items: [
- {
- userId: "u_1",
- account: "k@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [
- { teamId: "t_a", teamName: "플랫폼", role: "edit" },
- { teamId: "t_b", teamName: "백엔드", role: "edit" },
- { teamId: "t_c", teamName: "프론트엔드", role: "edit" },
- ],
- lastAccessAt: "2026-07-07T08:12:00Z",
- lastInvitedAt: "2026-07-06T09:00:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_2",
- account: "m@corp.com",
- invitationStatus: "invite_pending",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_b", teamName: "백엔드", role: "read" }],
- lastAccessAt: null,
- lastInvitedAt: "2026-07-05T18:20:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_3",
- account: "n@corp.com",
- invitationStatus: "invite_expired",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_b", teamName: "백엔드", role: "write" }],
- lastAccessAt: null,
- lastInvitedAt: "2026-07-03T10:00:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_4",
- account: "p@corp.com",
- invitationStatus: "invite_pending",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_c", teamName: "프론트엔드", role: "read" }],
- lastAccessAt: null,
- lastInvitedAt: "2026-07-06T15:40:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_5",
- account: "q@corp.com",
- invitationStatus: "invite_expired",
- sessionStatus: "offline",
- memberships: [
- { teamId: "t_a", teamName: "플랫폼", role: "read" },
- { teamId: "t_d", teamName: "디자인", role: "read" },
- ],
- lastAccessAt: null,
- lastInvitedAt: "2026-07-02T09:30:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_6",
- account: "r@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_e", teamName: "보안", role: "write" }],
- lastAccessAt: "2026-07-04T11:30:00Z",
- lastInvitedAt: "2026-07-01T10:10:00Z",
- sessionExpiredAt: "2026-07-06T17:05:00Z",
- },
- {
- userId: "u_7",
- account: "s@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [
- { teamId: "t_d", teamName: "디자인", role: "edit" },
- { teamId: "t_f", teamName: "볼트", role: "read" },
- ],
- lastAccessAt: "2026-07-06T18:40:00Z",
- lastInvitedAt: "2026-07-05T15:02:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_8",
- account: "t@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [{ teamId: "t_e", teamName: "보안", role: "read" }],
- lastAccessAt: "2026-07-07T07:55:00Z",
- lastInvitedAt: "2026-07-04T11:30:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_9",
- account: "u@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [{ teamId: "t_b", teamName: "백엔드", role: "write" }],
- lastAccessAt: "2026-07-06T21:10:00Z",
- lastInvitedAt: "2026-07-01T09:20:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_10",
- account: "v@corp.com",
- invitationStatus: "invite_pending",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_b", teamName: "백엔드", role: "read" }],
- lastAccessAt: null,
- lastInvitedAt: "2026-07-06T10:20:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_11",
- account: "w@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "offline",
- memberships: [
- { teamId: "t_b", teamName: "백엔드", role: "edit" },
- { teamId: "t_gql", teamName: "쿼리 API", role: "read" },
- ],
- lastAccessAt: "2026-07-03T13:40:00Z",
- lastInvitedAt: "2026-06-28T09:00:00Z",
- sessionExpiredAt: "2026-07-05T09:00:00Z",
- },
- {
- userId: "u_12",
- account: "x@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [{ teamId: "t_con", teamName: "콘솔", role: "edit" }],
- lastAccessAt: "2026-07-07T06:45:00Z",
- lastInvitedAt: "2026-07-02T14:00:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_13",
- account: "y@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [
- { teamId: "t_sre", teamName: "사이트 신뢰성", role: "write" },
- { teamId: "t_obs", teamName: "관측성", role: "read" },
- ],
- lastAccessAt: "2026-07-06T23:50:00Z",
- lastInvitedAt: "2026-07-01T08:10:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_14",
- account: "z@corp.com",
- invitationStatus: "invite_pending",
- sessionStatus: "offline",
- memberships: [
- { teamId: "t_fhe", teamName: "동형암호 코어", role: "read" },
- ],
- lastAccessAt: null,
- lastInvitedAt: "2026-07-07T08:30:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_15",
- account: "aa@corp.com",
- invitationStatus: "invite_expired",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_mkt", teamName: "마케팅", role: "read" }],
- lastAccessAt: null,
- lastInvitedAt: "2026-06-25T16:00:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_16",
- account: "ab@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [
- { teamId: "t_mob", teamName: "모바일", role: "edit" },
- { teamId: "t_ios", teamName: "아이폰 앱", role: "write" },
- { teamId: "t_and", teamName: "안드로이드 앱", role: "write" },
- { teamId: "t_con", teamName: "콘솔", role: "read" },
- { teamId: "t_ds", teamName: "디자인 시스템", role: "read" },
- ],
- lastAccessAt: "2026-07-05T14:20:00Z",
- lastInvitedAt: "2026-06-30T11:00:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_17",
- account: "external.partner.jihoon.kim@verylong-partner-domain.co.kr",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [{ teamId: "t_ana", teamName: "분석", role: "read" }],
- lastAccessAt: "2026-07-04T09:12:00Z",
- lastInvitedAt: "2026-07-01T10:30:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_18",
- account: "ac@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_net", teamName: "네트워크", role: "write" }],
- lastAccessAt: "2026-06-30T18:25:00Z",
- lastInvitedAt: "2026-06-27T09:40:00Z",
- sessionExpiredAt: "2026-07-02T09:40:00Z",
- },
- {
- userId: "u_19",
- account: "ad@corp.com",
- invitationStatus: "invite_pending",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_prod", teamName: "프로덕트", role: "read" }],
- lastAccessAt: null,
- lastInvitedAt: "2026-07-05T11:00:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_20",
- account: "ae@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [
- { teamId: "t_trn", teamName: "학습", role: "edit" },
- { teamId: "t_inf", teamName: "추론", role: "edit" },
- ],
- lastAccessAt: "2026-07-06T16:05:00Z",
- lastInvitedAt: "2026-07-03T13:15:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_21",
- account: "af@corp.com",
- invitationStatus: "invite_expired",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_key", teamName: "키 관리", role: "read" }],
- lastAccessAt: null,
- lastInvitedAt: "2026-06-24T15:30:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_22",
- account: "ag@corp.com",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- memberships: [{ teamId: "t_gw", teamName: "게이트웨이", role: "write" }],
- lastAccessAt: "2026-07-07T05:58:00Z",
- lastInvitedAt: "2026-07-04T10:05:00Z",
- sessionExpiredAt: null,
- },
- {
- userId: "u_23",
- account: "ah@corp.com",
- invitationStatus: "invite_pending",
- sessionStatus: "offline",
- memberships: [{ teamId: "t_edu", teamName: "교육", role: "read" }],
- lastAccessAt: null,
- lastInvitedAt: "2026-07-06T19:45:00Z",
- sessionExpiredAt: null,
- },
- ],
-};
-
-/**
- * GET /teams/t_b/members?page=1&size=10 — team Backend's member table
- * (extends the SC-06 wireframe fixture; roles/statuses mirror the t_b
- * memberships in DUMMY_USERS). joinedAt is null until the invite is
- * accepted (wireframe shows "—").
- */
-export const DUMMY_TEAM_B_MEMBERS: TApiPage = {
- total: 6,
- page: 1,
- size: 10,
- items: [
- {
- userId: "u_1",
- account: "k@corp.com",
- role: "edit",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- joinedAt: "2026-07-02T00:00:00Z",
- },
- {
- userId: "u_2",
- account: "m@corp.com",
- role: "read",
- invitationStatus: "invite_pending",
- sessionStatus: "offline",
- joinedAt: null,
- },
- {
- userId: "u_3",
- account: "n@corp.com",
- role: "write",
- invitationStatus: "invite_expired",
- sessionStatus: "offline",
- joinedAt: null,
- },
- {
- userId: "u_9",
- account: "u@corp.com",
- role: "write",
- invitationStatus: "invite_redeemed",
- sessionStatus: "online",
- joinedAt: "2026-07-02T00:00:00Z",
- },
- {
- userId: "u_10",
- account: "v@corp.com",
- role: "read",
- invitationStatus: "invite_pending",
- sessionStatus: "offline",
- joinedAt: null,
- },
- {
- userId: "u_11",
- account: "w@corp.com",
- role: "edit",
- invitationStatus: "invite_redeemed",
- sessionStatus: "offline",
- joinedAt: "2026-06-29T00:00:00Z",
- },
- ],
-};
diff --git a/frontend/src/state/store/__tests__/alertStore.test.ts b/frontend/src/state/store/__tests__/alertStore.test.ts
deleted file mode 100644
index ee5adec..0000000
--- a/frontend/src/state/store/__tests__/alertStore.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { beforeEach, describe, expect, it } from "vitest";
-
-import { useAlertStore } from "@/state/store/alertStore";
-
-const resetAlertStore = () => {
- useAlertStore.setState({
- alert: { title: "", content: "", isOpen: false },
- });
-};
-
-describe("alertStore", () => {
- beforeEach(() => {
- resetAlertStore();
- });
-
- it("opens the alert with the given content", () => {
- const { setAlert } = useAlertStore.getState();
- setAlert({ title: "오류", content: "문제가 발생했습니다." });
-
- const { alert } = useAlertStore.getState();
- expect(alert).toEqual({
- title: "오류",
- content: "문제가 발생했습니다.",
- isOpen: true,
- });
- });
-
- it("resets to the closed state on closeAlert", () => {
- const { setAlert, closeAlert } = useAlertStore.getState();
- setAlert({ title: "오류", content: "문제가 발생했습니다." });
- closeAlert();
-
- expect(useAlertStore.getState().alert.isOpen).toBe(false);
- });
-});
diff --git a/frontend/src/stores/__tests__/noticeStore.test.ts b/frontend/src/state/store/__tests__/noticeStore.test.ts
similarity index 96%
rename from frontend/src/stores/__tests__/noticeStore.test.ts
rename to frontend/src/state/store/__tests__/noticeStore.test.ts
index ddf01c3..c62862d 100644
--- a/frontend/src/stores/__tests__/noticeStore.test.ts
+++ b/frontend/src/state/store/__tests__/noticeStore.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { useNoticeStore } from "@/stores/noticeStore";
+import { useNoticeStore } from "@/state/store/noticeStore";
describe("noticeStore", () => {
beforeEach(() => {
diff --git a/frontend/src/stores/__tests__/workspaceStore.test.ts b/frontend/src/state/store/__tests__/workspaceStore.test.ts
similarity index 94%
rename from frontend/src/stores/__tests__/workspaceStore.test.ts
rename to frontend/src/state/store/__tests__/workspaceStore.test.ts
index 3c2ded8..81f089e 100644
--- a/frontend/src/stores/__tests__/workspaceStore.test.ts
+++ b/frontend/src/state/store/__tests__/workspaceStore.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
-import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { useWorkspaceStore } from "@/state/store/workspaceStore";
const reset = () =>
useWorkspaceStore.setState({ modalOpen: false, deleteConfirmOpen: false });
diff --git a/frontend/src/state/store/alertStore.ts b/frontend/src/state/store/alertStore.ts
deleted file mode 100644
index b0eea68..0000000
--- a/frontend/src/state/store/alertStore.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { create } from "zustand";
-
-import { type TAlert } from "@/types/commonTypes";
-
-type ExtendedTAlert = TAlert & { isOpen: boolean };
-
-interface AlertStoreProps {
- alert: ExtendedTAlert;
- setAlert: (target: TAlert) => void;
- closeAlert: () => void;
-}
-
-const initialAlert: ExtendedTAlert = { title: "", content: "", isOpen: false };
-
-/** useAlertStore drives the global alert modal (wireframe alert pattern). */
-export const useAlertStore = create((set) => ({
- alert: initialAlert,
- setAlert: (target: TAlert) =>
- set((state) => ({ ...state, alert: { ...target, isOpen: true } })),
- closeAlert: () => set((state) => ({ ...state, alert: initialAlert })),
-}));
diff --git a/frontend/src/stores/noticeStore.ts b/frontend/src/state/store/noticeStore.ts
similarity index 100%
rename from frontend/src/stores/noticeStore.ts
rename to frontend/src/state/store/noticeStore.ts
diff --git a/frontend/src/stores/toastStore.ts b/frontend/src/state/store/toastStore.ts
similarity index 100%
rename from frontend/src/stores/toastStore.ts
rename to frontend/src/state/store/toastStore.ts
diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/state/store/workspaceStore.ts
similarity index 100%
rename from frontend/src/stores/workspaceStore.ts
rename to frontend/src/state/store/workspaceStore.ts
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index 7f3db81..fb6a8f5 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -3,6 +3,12 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
+// Pin the test language to Korean BEFORE any module imports @/locales (setup
+// runs ahead of test-file module graphs; the locale is chosen once at import):
+// the suite's assertions are written against the Korean copy, and jsdom's
+// navigator.language would otherwise pick en.
+window.localStorage.setItem("rc_lang", "ko");
+
// Auto-cleanup is not registered by Testing Library when vitest runs with
// globals: false, so register it explicitly.
afterEach(() => {
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/frontend/src/types/commonTypes.ts b/frontend/src/types/commonTypes.ts
index 6250152..4085a6d 100644
--- a/frontend/src/types/commonTypes.ts
+++ b/frontend/src/types/commonTypes.ts
@@ -1,8 +1,3 @@
-export type TAlert = {
- title: string;
- content: string;
-};
-
/** Option shape for the shared Dropdown element (UIKIT AdminOption). */
export type TDropdownOption = {
value: string;
@@ -15,64 +10,5 @@ export type TDropdownOption = {
/** Session chip state — the only status a list view renders. */
export type TMemberStatus = "online" | "offline";
-/**
- * rune workspace lifecycle phase (wireframe SC-03 badge; console API
- * `phase`). `provisioning` is the transient state right after create,
- * before the endpoint/row count exist.
- */
-export type TWorkspaceStatus =
- | "provisioning"
- | "running"
- | "stopping"
- | "stopped"
- | "starting"
- | "deleting"
- | "error";
-
-/**
- * rune workspace record surfaced in the console (wireframe SC-02 state D),
- * mapped from the API `GET /workspace` body. The workspace name is never
- * exposed — it is a hash-like random value stored DB-side only. endpoint and
- * rowCount are null until the workspace finishes provisioning.
- */
-export type TWorkspace = {
- status: TWorkspaceStatus;
- endpoint: string | null;
- rowCount: number | null;
- /**
- * The workspace exists in the cloud but was created by a different console
- * install than this one (a reinstall minted a fresh team_secret), so its
- * stored data is encrypted under a key we no longer hold and it can only be
- * deleted + recreated. Absent/false on a healthy workspace.
- */
- orphaned: boolean;
- /**
- * The data-plane credential expired and a background reconnect cannot
- * re-bootstrap it — the user must drive a reconnect (POST /workspace). The
- * cloud workspace itself is healthy; only the local engine link is stale.
- * Mutually exclusive with orphaned (recreate supersedes reconnect).
- */
- reconnectRequired: boolean;
-};
-
-/** Wire shape of `GET /workspace` (console API design 2026-07-13, §Workspace). */
-export type TWorkspaceWire = {
- phase: TWorkspaceStatus;
- endpointUrl: string | null;
- rows: number | null;
- /** true when the workspace no longer matches this console (reinstall). */
- orphaned?: boolean;
- /** true when the data-plane credential expired and needs a user-driven reconnect. */
- reconnect?: boolean;
-};
-
-/** Recursive team-tree node (UIKIT AdminTeamNode, wireframe SC-06). */
-export type TTeamNode = {
- id: string;
- name: string;
- members: number;
- children?: TTeamNode[];
-};
-
/** Toast tone — semantic colors are state, not decoration. */
export type TToastTone = "info" | "success" | "error";
diff --git a/frontend/src/types/teamTypes.ts b/frontend/src/types/teamTypes.ts
index 41de255..1c0e96b 100644
--- a/frontend/src/types/teamTypes.ts
+++ b/frontend/src/types/teamTypes.ts
@@ -1,3 +1,9 @@
+import type {
+ INVITATION_STATUS,
+ SESSION_STATUS,
+ TEAM_MEMBER_ROLE,
+} from "@/constants/apiConstants";
+
export type TTeamNode = {
id: string;
name: string;
@@ -9,15 +15,27 @@ export type TTeamNode = {
export type TTeamTree = TTeamNode[];
-/** Grantable member role (Admin is console-account only — API §0). */
-export type TTeamMemberRole = "edit" | "write" | "read";
+/** Recursive team-tree node the tree/org views consume (UIKIT
+ AdminTeamNode, wireframe SC-06) — built client-side from the flat
+ TTeamTree. Distinct from TTeamNode, the flat wire row above. */
+export type TTeamViewNode = {
+ id: string;
+ name: string;
+ members: number;
+ children?: TTeamViewNode[];
+};
+
+/** Grantable member role — derived from TEAM_MEMBER_ROLE (single source). */
+export type TTeamMemberRole =
+ (typeof TEAM_MEMBER_ROLE)[keyof typeof TEAM_MEMBER_ROLE];
-/** Invitation-code lifecycle status on the wire (common contract). */
+/** Invitation-code lifecycle status — derived from INVITATION_STATUS. */
export type TInvitationStatus =
- "invite_pending" | "invite_expired" | "invite_redeemed";
+ (typeof INVITATION_STATUS)[keyof typeof INVITATION_STATUS];
-/** Session-token liveness on the wire (common contract). */
-export type TSessionStatus = "online" | "offline";
+/** Session-token liveness — derived from SESSION_STATUS. */
+export type TSessionStatus =
+ (typeof SESSION_STATUS)[keyof typeof SESSION_STATUS];
/** GET /teams/{id} detail. */
export type TTeamDetail = {
diff --git a/frontend/src/types/updateTypes.ts b/frontend/src/types/updateTypes.ts
index 95c6ea7..501551a 100644
--- a/frontend/src/types/updateTypes.ts
+++ b/frontend/src/types/updateTypes.ts
@@ -1,6 +1,9 @@
-/** Lifecycle reported by the privileged rune-console update agent. */
+import type { SYSTEM_UPDATE_STATE } from "@/constants/apiConstants";
+
+/** Lifecycle reported by the privileged rune-console update agent —
+ derived from SYSTEM_UPDATE_STATE (single source). */
export type TSystemUpdateState =
- "idle" | "queued" | "running" | "failed" | "succeeded";
+ (typeof SYSTEM_UPDATE_STATE)[keyof typeof SYSTEM_UPDATE_STATE];
/** Wire contract for GET /api/v1/system/update. */
export type TSystemUpdateStatus = {
diff --git a/frontend/src/types/workspaceTypes.ts b/frontend/src/types/workspaceTypes.ts
new file mode 100644
index 0000000..d495628
--- /dev/null
+++ b/frontend/src/types/workspaceTypes.ts
@@ -0,0 +1,47 @@
+import type { WORKSPACE_STATUS } from "@/constants/apiConstants";
+
+/**
+ * rune workspace lifecycle phase (wireframe SC-03 badge; console API
+ * `phase`). `provisioning` is the transient state right after create,
+ * before the endpoint/row count exist. Derived from WORKSPACE_STATUS
+ * (single source).
+ */
+export type TWorkspaceStatus =
+ (typeof WORKSPACE_STATUS)[keyof typeof WORKSPACE_STATUS];
+
+/**
+ * rune workspace record surfaced in the console (wireframe SC-02 state D),
+ * mapped from the API `GET /workspace` body. The workspace name is never
+ * exposed — it is a hash-like random value stored DB-side only. endpoint and
+ * rowCount are null until the workspace finishes provisioning.
+ */
+export type TWorkspace = {
+ status: TWorkspaceStatus;
+ endpoint: string | null;
+ rowCount: number | null;
+ /**
+ * The workspace exists in the cloud but was created by a different console
+ * install than this one (a reinstall minted a fresh team_secret), so its
+ * stored data is encrypted under a key we no longer hold and it can only be
+ * deleted + recreated. Absent/false on a healthy workspace.
+ */
+ orphaned: boolean;
+ /**
+ * The data-plane credential expired and a background reconnect cannot
+ * re-bootstrap it — the user must drive a reconnect (POST /workspace). The
+ * cloud workspace itself is healthy; only the local engine link is stale.
+ * Mutually exclusive with orphaned (recreate supersedes reconnect).
+ */
+ reconnectRequired: boolean;
+};
+
+/** Wire shape of `GET /workspace` (console API design 2026-07-13, §Workspace). */
+export type TWorkspaceWire = {
+ phase: TWorkspaceStatus;
+ endpointUrl: string | null;
+ rows: number | null;
+ /** true when the workspace no longer matches this console (reinstall). */
+ orphaned?: boolean;
+ /** true when the data-plane credential expired and needs a user-driven reconnect. */
+ reconnect?: boolean;
+};
diff --git a/frontend/src/components/teams/__tests__/teamHierarchy.test.ts b/frontend/src/utils/__tests__/teamHierarchy.test.ts
similarity index 93%
rename from frontend/src/components/teams/__tests__/teamHierarchy.test.ts
rename to frontend/src/utils/__tests__/teamHierarchy.test.ts
index 937ce51..547386a 100644
--- a/frontend/src/components/teams/__tests__/teamHierarchy.test.ts
+++ b/frontend/src/utils/__tests__/teamHierarchy.test.ts
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
-import {
- getTeamDescendantIds,
- getTeamName,
-} from "@/components/teams/teamHierarchy";
+import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy";
import type { TTeamTree } from "@/types/teamTypes";
/** Minimal 3-node chain: t_1 root → t_2 child → t_3 grandchild. */
diff --git a/frontend/src/components/teams/teamOptions.ts b/frontend/src/utils/buildTeamOptions.ts
similarity index 55%
rename from frontend/src/components/teams/teamOptions.ts
rename to frontend/src/utils/buildTeamOptions.ts
index 2415780..13421f1 100644
--- a/frontend/src/components/teams/teamOptions.ts
+++ b/frontend/src/utils/buildTeamOptions.ts
@@ -1,19 +1,7 @@
+import { localizeTeamName } from "@/utils/teamHierarchy";
import type { TDropdownOption } from "@/types/commonTypes";
import type { TTeamTree } from "@/types/teamTypes";
-/** 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[] = [
- { value: "edit", label: "edit" },
- { value: "write", label: "write" },
- { value: "read", label: "read" },
-];
-
/** All teams in tree order with depth indent (for team-picker dropdowns).
Pure function over the real `teams` query result — used by the team
CRUD modals (create/rename/delete) and the Users page pickers. */
@@ -22,7 +10,7 @@ export const buildTeamOptions = (teams: TTeamTree): TDropdownOption[] => {
teams
.filter((t) => t.parentId === parentId)
.flatMap((t) => [
- { value: t.id, label: t.name, depth },
+ { value: t.id, label: localizeTeamName(t.name), depth },
...walk(t.id, depth + 1),
]);
return walk(null, 0);
diff --git a/frontend/src/utils/email.ts b/frontend/src/utils/email.ts
new file mode 100644
index 0000000..d159f6c
--- /dev/null
+++ b/frontend/src/utils/email.ts
@@ -0,0 +1,5 @@
+/** Email (account) field rules — shared by the invite form (SC-12) and the
+ * team add-member form (SC-06), mirroring the username.ts convention of
+ * keeping a field's pattern and its validation copy together. */
+
+export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
diff --git a/frontend/src/utils/formatDate.ts b/frontend/src/utils/formatDate.ts
index 2dea4a6..091991c 100644
--- a/frontend/src/utils/formatDate.ts
+++ b/frontend/src/utils/formatDate.ts
@@ -6,19 +6,23 @@
const KST_TIME_ZONE = "Asia/Seoul";
+/* Constructed once at module load — Intl.DateTimeFormat construction is
+ one of the costlier Intl operations and these run in every table cell
+ on every render; the options never change. */
+const KST_FORMATTER = new Intl.DateTimeFormat("en-US", {
+ timeZone: KST_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hourCycle: "h23",
+});
+
/** Break an ISO instant into zero-padded KST calendar parts. */
const kstParts = (iso: string): Record => {
- const formatter = new Intl.DateTimeFormat("en-US", {
- timeZone: KST_TIME_ZONE,
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- hourCycle: "h23",
- });
const parts: Record = {};
- for (const { type, value } of formatter.formatToParts(new Date(iso))) {
+ for (const { type, value } of KST_FORMATTER.formatToParts(new Date(iso))) {
parts[type] = value;
}
return parts;
diff --git a/frontend/src/components/users/invitePreview.ts b/frontend/src/utils/invitePreview.ts
similarity index 82%
rename from frontend/src/components/users/invitePreview.ts
rename to frontend/src/utils/invitePreview.ts
index 65e4027..fb42cc5 100644
--- a/frontend/src/components/users/invitePreview.ts
+++ b/frontend/src/utils/invitePreview.ts
@@ -1,9 +1,7 @@
-import {
- getTeamDescendantIds,
- getTeamName,
-} from "@/components/teams/teamHierarchy";
+import { getTeamDescendantIds, getTeamName } from "@/utils/teamHierarchy";
import type { TTeamTree } from "@/types/teamTypes";
import type { TInviteSet } from "@/types/userTypes";
+import { L } from "@/locales";
/** One row of the 하위 팀 권한 미리보기 table (SC-12 no.3). */
export type TInvitePreviewRow = {
@@ -20,7 +18,7 @@ export type TInvitePreviewRow = {
* each selected team contributes its sub-teams at the same role. A team
* that is both directly specified and covered by another set's subtree
* keeps its direct role ("직접 지정이 우선") and is flagged
- * "이미 초대된 팀입니다." in the descendant position (SC-12 no.3 — D3).
+ * L.teams.alreadyInvited in the descendant position (SC-12 no.3 — D3).
* The row list doubles as the membership expansion the server would
* produce, so the dummy invite mutation reuses it.
*/
@@ -39,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)) {
@@ -51,8 +51,8 @@ export const buildInvitePreview = (
teamName: getTeamName(teams, descId),
role: direct ?? set.role,
reason: direct
- ? "이미 초대된 팀입니다."
- : `${getTeamName(teams, set.teamId)} 하위 팀`,
+ ? L.teams.alreadyInvited
+ : L.members.subteamOf(getTeamName(teams, set.teamId)),
indent: true,
});
}
diff --git a/frontend/src/utils/teamHierarchy.ts b/frontend/src/utils/teamHierarchy.ts
new file mode 100644
index 0000000..84c50f2
--- /dev/null
+++ b/frontend/src/utils/teamHierarchy.ts
@@ -0,0 +1,83 @@
+import type { TTeamTree, TTeamViewNode } from "@/types/teamTypes";
+import { L } from "@/locales";
+
+/**
+ * Team-tree lookups over a flat `TTeamTree` — shared by the invite preview
+ * (SC-12 no.3) and the membership-removal sub-team notice (SC-14 no.2).
+ * Pure functions over the tree passed in (from `useTeamsTreeQuery`); trees
+ * are small, so no memoized id-map is kept at module scope.
+ */
+
+/**
+ * DEMO ONLY. Maps the mock server's English sample team names (Platform,
+ * Data, …) to the active language so the demo reads naturally when toggled.
+ * Real, customer-created names are not in L.demoTeams, so they pass through
+ * verbatim — team names are user data and must never be auto-translated in
+ * production. Read at render time (the `key={lang}` remount re-runs the
+ * callers), so it follows a live language switch. Delete/neutralize this
+ * once the app talks to a real backend with real team names.
+ */
+export const localizeTeamName = (name: string): string =>
+ (L.demoTeams as Record)[name] ?? name;
+
+/** Team name for `teamId`, or the id itself if the team is unknown. */
+export const getTeamName = (teams: TTeamTree, teamId: string): string =>
+ localizeTeamName(teams.find((team) => team.id === teamId)?.name ?? teamId);
+
+/** All descendant ids of a team, in depth-first tree order. */
+export const getTeamDescendantIds = (
+ teams: TTeamTree,
+ teamId: string,
+): string[] =>
+ (teams.find((team) => team.id === teamId)?.childrenIds ?? []).flatMap(
+ (childId) => [childId, ...getTeamDescendantIds(teams, childId)],
+ );
+
+/**
+ * GET /teams/tree returns flat nodes — the client builds the recursive
+ * TTeamViewNode shape the TeamTree component consumes (API design §3).
+ * Single pass over a children index (not a filter per parent), so the
+ * build stays linear in team count. Callers memoize per teams array.
+ */
+export const buildTeamNodes = (teams: TTeamTree): TTeamViewNode[] => {
+ const childrenOf = new Map();
+ for (const team of teams) {
+ const siblings = childrenOf.get(team.parentId);
+ if (siblings) siblings.push(team);
+ else childrenOf.set(team.parentId, [team]);
+ }
+ const build = (parentId: string | null): TTeamViewNode[] =>
+ (childrenOf.get(parentId) ?? []).map((team) => ({
+ id: team.id,
+ name: localizeTeamName(team.name),
+ members: team.memberCount,
+ children: team.childCount > 0 ? build(team.id) : undefined,
+ }));
+ return build(null);
+};
+
+/** Depth-first lookup in a built view-node tree. */
+export const findTeamNode = (
+ nodes: TTeamViewNode[],
+ id: string,
+): TTeamViewNode | undefined =>
+ nodes.reduce(
+ (found, node) =>
+ found ?? (node.id === id ? node : findTeamNode(node.children ?? [], id)),
+ undefined,
+ );
+
+/** Ancestor ids of a team — expanded so a selection handed off from
+ the org chart is actually visible in the tree. */
+export const ancestorIds = (
+ flatById: Map,
+ teamId: string,
+): string[] => {
+ const ids: string[] = [];
+ let parentId = flatById.get(teamId)?.parentId;
+ while (parentId) {
+ ids.push(parentId);
+ parentId = flatById.get(parentId)?.parentId;
+ }
+ return ids;
+};
diff --git a/frontend/src/utils/username.ts b/frontend/src/utils/username.ts
index ee1e945..c002527 100644
--- a/frontend/src/utils/username.ts
+++ b/frontend/src/utils/username.ts
@@ -1,10 +1,9 @@
/** Username (표시용 사용자 이름) rules — API design §공통 계약 (2026-07-20):
* 한글·영문 소문자만, 단어 사이 공백 1칸(양끝 공백 금지), 1~50자. */
-export const USERNAME_MAX_LENGTH = 50;
+import { L } from "@/locales";
-export const USERNAME_FORMAT_ERROR =
- "한글, 영문 소문자, 단어 사이 공백 1칸만 입력할 수 있습니다.";
+export const USERNAME_MAX_LENGTH = 50;
/* Compatibility jamo (ㄱ-ㅎ·ㅏ-ㅣ) are accepted while typing so an
in-progress IME composition never flags as invalid mid-keystroke;
@@ -30,7 +29,7 @@ export const normalizeUsernameInput = (value: string): string =>
export const validateUsername = (value: string): string | undefined => {
if (value === "") return undefined;
if (value.length > USERNAME_MAX_LENGTH || !TYPING_PATTERN.test(value))
- return USERNAME_FORMAT_ERROR;
+ return L.validation.usernameRule;
return undefined;
};
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()})
}