Admin 내비게이션 재구성: 사이드바 항목 17개에서 기능별 Settings 탭이 포함된 10개의 평면 항목으로
README 링크에서 발견 · today
비대해진 관리자 사이드바를 섹션 탭과 URL을 사용해 10개 항목으로 축소했습니다
What they did
관리자 영역은 네 개의 그룹 제목 아래 17개의 사이드바 항목으로 커졌고, 휴대폰에서는 햄버거 시트와 탭 바가 함께 있었으며, /admin/settings 페이지 하나에 여섯 그룹으로 나뉜 16개의 섹션이 있었습니다. 어떤 기능의 컨트롤을 찾으려면 그것이 해당 기능 페이지에 있는지 Settings 어딘가에 있는지를 알아야 했습니다. 사용성 감사 결과를 바탕으로, 저자는 재구성을 계획하고 이를 별도의 worktree에서 구현 에이전트들이 수행하는 열세 개의 작업으로 실행했으며, 각 작업에는 작업 범위의 리뷰(스펙 준수 및 코드 품질), 리뷰에서 Important 이슈가 발견된 경우의 수정 라운드, 양쪽의 합집합으로서의 병합, 마지막에 전체 브랜치 리뷰와 범위가 정해진 재리뷰가 포함된 한 차례의 수정 웨이브가 있었습니다. 작업별 커밋은 유지되어 조각들을 개별적으로 체리픽하거나 되돌릴 수 있습니다. 결과는 다음과 같습니다: 그룹 제목이 없는 열 개의 평면 사이드바 항목(Overview, Assistant, Members, Announcements, Rewind, Discord, AI, Automations, Audit log, Settings), 각 기능의 컨트롤은 Settings 탭으로서 자체 페이지에 위치하며, /admin/settings는 전역적인 것(Connections, Billing, System)만을 위해 유지됩니다. lib/admin/nav.ts는 단일 목록으로 남아 있으며, 항목들은 children(탭 스트립)과 also(항목을 활성화하는 형제 경로)를 가집니다; 사이드바, 모바일 탭 바, 페이지 제목, 커맨드 메뉴, 그리고 각 섹션의 탭 스트립이 모두 이를 읽습니다. URL은 변경되지 않습니다: 형제 라우트는 섹션별 라우트 그룹을 통해 경로를 유지하며, 각 그룹의 layout.tsx는 export default sectionLayout("<id>")로, 이는 항목의 허용된 children으로부터 SectionTabs를 그립니다. 네 개의 새 페이지가 예전에 Settings에 있던 섹션들을 동일한 컴포넌트, LastSaved 푸터, 테스트 id와 함께 호스팅합니다. 예전 /admin/settings#<이동된 id> 링크는 섹션의 새 페이지로 리다이렉트됩니다. 휴대폰에서는 이제 하나의 메뉴가 있습니다: 햄버거 시트는 사라졌고 탭 바는 Overview, Members, Rewind, Automations, More입니다. 커맨드 메뉴는 Page › Tab 항목들을 나열하며, Support 역할은 허용된 항목만 볼 수 있습니다.
How it went
리팩터링은 typecheck, lint(오류 0개), design-system --strict, knip, env:docs --check, mcp:docs --check가 최종 head에서 모두 통과한 단일 PR로 머지되었습니다. 전체 jest에서는 두 건의 실패만 있었는데, 이는 main에서도 동일하게 실패합니다. Playwright는 CI에서 실행됩니다. 시각적 베이스라인은 드리프트가 발생할 것이며(사이드바, phone chrome, Settings 내비게이션), advisory Visual Diffs 작업에서 이를 표시하고 Visual Baselines 워크플로가 이를 갱신합니다. 이 PR에서 수정하지 않고 네 가지 후속 작업을 등록했습니다: dirty 상태의 독립형 섹션에서 클라이언트 측 내비게이션으로 이탈할 때 프롬프트 없이 초안이 삭제되는 문제(#515), 커맨드 메뉴가 라벨의 단일 부분 문자열만 매칭하여 이전 이름이 더 이상 매칭되지 않는 문제(#516), navFor가 게이팅된 항목의 href를 첫 번째 허용된 탭으로 재작성하여 가상의 역할에서 항목이 활성화되지 않을 수 있는 문제(#517), 그리고 Support의 사용자 목록과 Overview Services 타일의 기존 깨진 링크(#518)입니다.
Worth knowing
레이아웃은 권한 경계가 아닙니다. 각 페이지는 자체적인 assertAdmin()/권한 검사를 유지하므로, 라우트 그룹을 접근 제어로 취급해서는 안 됩니다. .claude/skills 경로 토큰이 변경되었고 CLAUDE.md는 그러한 변경에 대해 /security-review를 요구합니다. 두 번의 리뷰에서 산문으로만 검토했지만, 별도의 /security-review는 실행되지 않았으며 이는 머지 전에 리뷰어가 실행하거나 면제할 사항으로 남겨둡니다. 초안 보호는 불완전합니다. beforeunload는 새로고침과 닫기만 커버하며, 탭이나 사이드바 전환은 커버하지 않습니다. 커맨드 메뉴의 단일 부분 문자열 매칭 때문에 "automations request" 같은 여러 단어 쿼리는 아무것도 찾지 못합니다. 머지 후 시각적 베이스라인을 갱신해야 합니다.
Wiring
`lib/admin/nav.ts`는 단일 내비게이션 목록이다. 항목은 children(탭 스트립)을 가지며, 동시에 (해당 항목을 활성화하는 형제 경로)도 가진다. 사이드바, 모바일 탭 바, 페이지 제목, 커맨드 메뉴, 그리고 각 섹션의 탭 스트립이 모두 이 목록을 읽는다. 형제 라우트는 섹션별 라우트 그룹(`app/admin/(members)/`, `(rewind)/`, `(ai)/`, `(automations)/`, 그리고 `discord/` 세그먼트)을 통해 경로를 유지한다. 각 그룹의 `layout.tsx`는 `export default sectionLayout("<id>")`이며, 이는 해당 항목의 허용된 children으로부터 SectionTabs를 그린다. 그리고 `/admin/users/[userId]` 같은 상세 라우트는 스트립을 렌더링하지 않는다. 레이아웃은 권한 경계가 아니다 — 모든 페이지는 자체적인 `assertAdmin()`/권한 검사를 유지한다. 네 개의 새 페이지(`/admin/rewind/settings`, `/admin/discord/settings`, `/admin/ai/settings`, `/admin/request-limits`)는 동일한 컴포넌트, LastSaved 푸터, 테스트 id를 재사용하며, 해당 섹션을 저장하는 액션은 새 페이지들을 revalidate한다. 기존 `/admin/settings#<이동된 id>` 링크는 섹션의 새 페이지로 리다이렉트된다. 내비게이션 프로바이더는 서버가 계산한 역할별 href와 허용된 children을 유지하므로, Support 사용자의 접힌 항목은 첫 번째 허용된 탭을 연다. `settingsSection(id)`는 설정 섹션으로 링크하는 유일한 방법이며, Object로부터 상속된 키는 무시한다. 공유 `useLeaveGuard`는 더 이상 Settings 레이아웃 안에 있지 않은 섹션에 대해 새로고침/닫기 프롬프트를 유지한다. 모든 섹션 탭에는 `loading.tsx`가 있다. CLAUDE.md에는 Admin 내비게이션 하위 섹션이 추가되어 다음 규칙을 담는다: 기능의 컨트롤은 기능의 페이지에 위치한다, 사이드바 예산은 10개 항목이다, 그리고 `settingsSection(id)`는 설정 섹션으로 링크하는 유일한 방법이다.Try it yourself
I can't carry out this task. I don't have access to your repository, so I can't read the current sidebar definition or the `/admin/settings` page, and I have no way to edit files, run typecheck/lint/tests, or verify that URLs stay unchanged.
What I can do is give you a concrete implementation plan you can execute (or hand to an agent with repo access). Here it is, mapped to your nine requirements.
## 1. Single nav module (source of truth)
Create `src/lib/nav/admin-nav.ts` (or wherever your nav lives) exporting one typed structure:
```ts
export type AdminTab = {
slug: string; // e.g. "billing"
label: string;
href: string; // canonical URL, e.g. "/admin/billing"
permission: string; // checked by the page itself
};
export type AdminNavItem = {
key: string;
label: string;
href: string; // landing URL for the section
icon?: ComponentType;
permission: string;
tabs: AdminTab[]; // children rendered as the tab strip
matchPaths: string[]; // sibling/detail paths that should light this item
};
export const ADMIN_NAV: AdminNavItem[] = [ /* ≤ 10 items, flat */ ];
```
Derive everything else from this: `getVisibleItems(perms)`, `getTabsForItem(key, perms)`, `findItemByPath(pathname)` (uses `matchPaths` + tab hrefs), and `getPageTitle(pathname)`.
## 2. Collapse to ≤ 10 flat items
- Inventory current groups; merge related groups into one item each.
- Delete group headings from the sidebar renderer; render `ADMIN_NAV` as a flat list.
- If you exceed 10, fold the least-used sections into an existing item's tabs rather than adding an 11th item.
## 3. Move feature settings onto feature pages as tabs
- For each `#<section>` currently on `/admin/settings`, decide its owning feature item.
- Create a route group per section, e.g. `app/admin/(billing)/billing/layout.tsx` + `page.tsx` + `[tab]/page.tsx`, or explicit tab routes if you prefer static segments.
- `/admin/settings` keeps only global concerns (org profile, auth/SSO, integrations that are truly cross-cutting, danger zone).
## 4. Route groups + tab strip layout, URLs unchanged
- Each section gets `app/admin/(<section>)/<section>/layout.tsx`.
- The layout reads `getTabsForItem(sectionKey, perms)` and renders the strip. It must **not** render a strip on detail routes — detect via `useSelectedLayoutSegment()`/`usePathname()` and skip when the segment isn't a known tab slug.
- Because the group is parenthesised, the URL stays `/admin/<section>/<tab>` — no redirects needed for the new pages.
## 5. Redirect old hash links
Hash fragments never reach the server, so a server redirect can't see `#billing`. Add a small client component on `/admin/settings` that reads `location.hash` on mount and `router.replace()`s to the mapped new URL. Keep a static map `{ "#billing": "/admin/billing", ... }` in the nav module so it stays in sync.
## 6. Mobile: tab bar, no hamburger
- Replace the sheet with a fixed bottom bar: up to 4 link tabs (from `ADMIN_NAV`, permission-filtered) + a "More" entry that opens a simple list (not a full sheet) of the remaining items.
- Cap logic: `items.slice(0, 4)` for links, rest behind More.
## 7. Command menu: `Page › Tab`
- Flatten `ADMIN_NAV` into `{ label: "Billing › Invoices", href, permission }` entries.
- Filter by permission before rendering; keep the existing fuzzy-match.
## 8. `loading.tsx` per section tab
- Add `loading.tsx` next to each tab route (or one per section if tabs share a skeleton). Use a shared skeleton component so they stay consistent.
## 9. Permissions stay on pages
- Every `page.tsx` keeps its own `requirePermission(...)` call.
- Layouts only *filter* what they render; they never gate access. Add a lint rule or code-review note so nobody "optimises" the check into the layout.
## Verification
- `pnpm typecheck && pnpm lint && pnpm test` (adjust to your scripts).
- Manually diff the route manifest before/after to confirm no URL changed.
- Grep for `#` links to `/admin/settings` and confirm each is covered by the hash-redirect map.
## Deliberately out of scope (follow-ups)
- Migrating any deep links in external docs/emails (only in-repo links handled).
- Reworking the permission model itself (e.g. per-tab vs per-page granularity).
- Visual redesign of the tab strip / mobile bar beyond structural changes.
- Analytics events for the new nav (old event names will silently stop firing).
- i18n string extraction for new labels.
- E2E tests for the mobile More menu and hash redirect (unit tests only if you have them).
If you paste the current sidebar file and the `/admin/settings` page, I can turn this into exact diffs.