Moves phases 08–11 execution artifacts from .planning/phases/ to
.planning/milestones/v0.2-phases/ to keep .planning/phases/ clean
for the next milestone.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
safelist for dynamic color classes (sky + amber included)
safelist
from
to
via
pattern
frontend/src/router/index.js
frontend/src/layouts/AdminLayout.vue
lazy import as /admin route component
import.*AdminLayout
from
to
via
pattern
frontend/src/router/index.js
frontend/src/views/admin/AdminOverviewView.vue
lazy import as /admin child path ''
AdminOverviewView
from
to
via
pattern
frontend/src/router/index.js beforeEach
Vue Router 4 matched array
to.matched.some(r => r.meta.requiresAdmin)
to.matched.some(r => r.meta.requiresAdmin)
Wire the router to use `AdminLayout.vue` as the `/admin` route component with five lazy-loaded children (one per admin view). Fix the `beforeEach` guard so it uses `to.matched.some(r => r.meta.requiresAdmin)` and apply D-09/D-10 strict admin role separation. Wire the login-success handler so admin users land at `/admin` and regular users at `/`. Add the Tailwind safelist (D-14 corrected to include `sky` and `amber`). Delete `AdminView.vue` and the four `AdminXxxTab.vue` files (D-12).
Purpose: ADMIN-08 (AdminLayout becomes the route component), ADMIN-10 (deep-linkable URLs), ADMIN-12 (to.matched.some() guard), CODE-06 (safelist), and the cleanup mandate from D-12 (delete dead AdminView.vue + four tab files).
Output: rewired router, fixed guard, login redirect, Tailwind safelist, and a clean repo with zero references to the deleted files.
@.planning/phases/09-admin-panel-rearchitecture/09-CONTEXT.md
@.planning/phases/09-admin-panel-rearchitecture/09-RESEARCH.md
@.planning/phases/09-admin-panel-rearchitecture/09-PATTERNS.md
@CLAUDE.md
@frontend/src/router/index.js
@frontend/src/views/auth/LoginView.vue
@frontend/tailwind.config.js
@frontend/src/stores/auth.js
@frontend/src/utils/formatters.js
From CONTEXT.md and RESEARCH.md (locked):
- D-08: admin login → `/admin`.
- D-09: admin on non-admin route → redirect `/admin`.
- D-10: non-admin on `/admin/*` → redirect `/`. Both checks use `to.matched.some(r => r.meta.requiresAdmin)`.
- D-14 (corrected by RESEARCH.md Pattern 7): safelist must include `sky` (formatters.js OneDrive) and `amber` (AuditLogTab `actionTypeClass()`):
```
safelist: [
{ pattern: /bg-(blue|sky|green|purple|orange|amber|gray|indigo|red)-(50|100|500|600)/ },
{ pattern: /text-(blue|sky|green|purple|orange|amber|gray|indigo|red)-(400|500|600|700)/ },
]
```
From RESEARCH.md Pattern 3 (LoginView.vue current code):
handleLoginResult(result) — when !result, currently const redirect = route.query.redirect || '/'; await router.push(redirect). Modify the !result branch only.
From frontend/src/stores/auth.js (verified RESEARCH.md):
user.value = data.user runs synchronously inside the login() action before it returns — so authStore.user.role is populated when handleLoginResult(!result) fires.
From PATTERNS.md (current router state):
frontend/src/router/index.js currently has { path: '/admin', component: () => import('../views/AdminView.vue'), meta: { requiresAdmin: true } } as a flat route.
Existing guard is if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') { return { path: '/' } } — the broken check that lets child routes through.
Auth-only routes (/login, /register, etc.) carry meta: { public: true, layout: 'auth' } — both flags MUST be preserved unchanged.
Other user routes (/, /topics, /folders/:id, /shared, /account, /settings, /cloud/...) have no requiresAdmin flag and no public flag → these are the routes that the D-09 admin-redirect MUST send admins away from.
Task 1: Rewire router/index.js — nested /admin + corrected guard + Tailwind safelist
frontend/src/router/index.js, frontend/tailwind.config.js
- frontend/src/router/index.js (full current file — locate the existing flat `/admin` route and the existing `beforeEach` guard for in-place replacement)
- frontend/tailwind.config.js (current 9-line config — add a `safelist:` array between `content:` and `theme:`)
- frontend/src/utils/formatters.js (confirm the dynamic class set; the safelist must cover every family referenced here)
- frontend/src/components/admin/AuditLogTab.vue (confirm `actionTypeClass()` uses `bg-amber-50 text-amber-700` plus blue/gray/purple families)
- The router replaces the existing flat `/admin` route with a nested route whose `component` is the lazy-loaded `AdminLayout.vue` and whose `children` are five lazy-loaded admin views in the order: `''` (Overview), `users`, `quotas`, `ai`, `audit`. Each lazy import uses `() => import('../views/admin/AdminView.vue')`.
- The parent `/admin` route carries `meta: { requiresAdmin: true }`. Child routes do NOT individually re-declare `requiresAdmin` — the guard relies on `to.matched.some()` to inherit from the parent.
- The `beforeEach` guard:
1. If route is non-public and `authStore.accessToken` is null, attempt silent refresh; on failure redirect to `/login?redirect=`.
2. Compute `isAdminRoute = to.matched.some(r => r.meta.requiresAdmin)`.
3. Compute `isAdmin = authStore.user?.role === 'admin'`.
4. D-10a: if `isAdminRoute && !isAdmin` → redirect `{ path: '/' }`.
5. D-09: if `!isAdminRoute && !to.meta.public && isAdmin` → redirect `{ path: '/admin' }`.
- Auth-public routes (`/login`, `/register`, `/forgot-password`, `/reset-password`) remain reachable for both admin and non-admin users (the `!to.meta.public` clause exempts them).
- The Tailwind config gains a `safelist` array with two regex patterns covering `bg-(blue|sky|green|purple|orange|amber|gray|indigo|red)-(50|100|500|600)` and `text-(blue|sky|green|purple|orange|amber|gray|indigo|red)-(400|500|600|700)`.
Open `frontend/src/router/index.js`. Locate the existing flat `/admin` route and replace it with the nested structure: parent `path: '/admin'`, `component: () => import('../layouts/AdminLayout.vue')`, `meta: { requiresAdmin: true }`, and a `children: [...]` array of five entries — `{ path: '', component: () => import('../views/admin/AdminOverviewView.vue') }`, then `{ path: 'users', component: () => import('../views/admin/AdminUsersView.vue') }`, then `quotas` → `AdminQuotasView.vue`, then `ai` → `AdminAiView.vue`, then `audit` → `AdminAuditView.vue`. Remove the old `() => import('../views/AdminView.vue')` reference entirely (Task 3 deletes that file). Replace the current `beforeEach` guard body with the 5-step structure from ``: refresh-then-guard, computing `isAdminRoute` via `to.matched.some(r => r.meta.requiresAdmin)`, then the two D-09 / D-10a redirect branches. Preserve the existing public-route bypass: every route currently bearing `meta: { public: true }` (login, register, password reset) must still resolve without the admin/non-admin checks firing — the `!to.meta.public` clauses in steps 4 and 5 ensure this. Do NOT remove `meta.layout: 'auth'` from any auth route — `App.vue` still reads it. Open `frontend/tailwind.config.js`. Add a `safelist:` property between the existing `content:` line and the `theme:` block. The array contains two objects with `pattern` regexes exactly as specified in ``. Do NOT alter the existing `content`, `theme`, or `plugins` lines.
cd frontend && grep -c "to.matched.some(r => r.meta.requiresAdmin)" src/router/index.js && grep -c "AdminLayout" src/router/index.js && grep -c "AdminOverviewView" src/router/index.js && grep -c "AdminUsersView" src/router/index.js && grep -c "AdminAuditView" src/router/index.js && grep -c "/admin" src/router/index.js && grep -c "safelist" tailwind.config.js && grep -c "amber" tailwind.config.js && grep -c "sky" tailwind.config.js && npm run build
- `grep -c "to.matched.some(r => r.meta.requiresAdmin)" frontend/src/router/index.js` returns at least 1.
- `grep -c "to.meta.requiresAdmin" frontend/src/router/index.js` returns 0 (broken flat check removed).
- `grep -c "AdminLayout" frontend/src/router/index.js` returns 1.
- `grep -c "AdminOverviewView" frontend/src/router/index.js` returns 1.
- `grep -c "AdminUsersView" frontend/src/router/index.js` returns 1.
- `grep -c "AdminQuotasView" frontend/src/router/index.js` returns 1.
- `grep -c "AdminAiView" frontend/src/router/index.js` returns 1.
- `grep -c "AdminAuditView" frontend/src/router/index.js` returns 1.
- `grep -c "AdminView" frontend/src/router/index.js` returns 0 (old flat ref gone).
- `grep -c "children:" frontend/src/router/index.js` returns at least 1.
- The two D-09 / D-10a redirect branches both exist: `grep -c "path: '/admin'" frontend/src/router/index.js` returns at least 2 (route + admin redirect) AND `grep -c "path: '/'" frontend/src/router/index.js` returns at least 2 (route + non-admin redirect).
- `grep -c "safelist" frontend/tailwind.config.js` returns 1.
- `grep -c "amber" frontend/tailwind.config.js` returns at least 1 (both patterns include `amber`).
- `grep -c "sky" frontend/tailwind.config.js` returns at least 1.
- `grep -E "blue\\|sky\\|green\\|purple\\|orange\\|amber\\|gray\\|indigo\\|red" frontend/tailwind.config.js | wc -l` returns at least 2 (one per pattern).
- `cd frontend && npm run build` succeeds.
Nested `/admin` route subtree wired; guard fixed; Tailwind safelist installed; build green.
Task 2: Wire admin login redirect in LoginView.vue
frontend/src/views/auth/LoginView.vue
- frontend/src/views/auth/LoginView.vue (full current file — locate `handleLoginResult`)
- frontend/src/stores/auth.js (confirm `user.role` is populated synchronously inside `login()`)
- After a fully successful login (`!result` branch), the function computes `defaultRedirect = authStore.user?.role === 'admin' ? '/admin' : '/'` and pushes to `route.query.redirect || defaultRedirect`.
- Other branches (`result.requires_totp`, `result.requires_password_change`) are unchanged.
- The `?redirect=` query param is honored as before — but only as a no-op default for non-admin users. (D-08 puts the role check FIRST so admins always land in `/admin`; the existing guard from Task 1 then prevents admin from following any `?redirect=/` to user routes via the D-09 branch.)
Open `frontend/src/views/auth/LoginView.vue`. Find the `handleLoginResult` function; in the `if (!result)` branch, replace the existing two lines (`const redirect = route.query.redirect || '/'` and `await router.push(redirect)`) with: compute `const defaultRedirect = authStore.user?.role === 'admin' ? '/admin' : '/'`, then `const redirect = route.query.redirect || defaultRedirect`, then `await router.push(redirect)`. The `authStore` variable name should already be in scope from the existing import — verify before adding any new imports.
cd frontend && grep -c "authStore.user?.role === 'admin'" src/views/auth/LoginView.vue && npm run build
- `grep -c "authStore.user?.role === 'admin'" frontend/src/views/auth/LoginView.vue` returns 1.
- `grep -c "defaultRedirect" frontend/src/views/auth/LoginView.vue` returns at least 2 (definition + use).
- The `if (!result)` branch still handles the `route.query.redirect` query param (the existing user-side redirect still works).
- Other branches (`requires_totp`, `requires_password_change`) are byte-for-byte identical to the previous version (no accidental edits).
- `cd frontend && npm run build` succeeds.
Admin login redirects to `/admin`; non-admin login retains existing behavior including optional `?redirect=` honored.
Task 3: Delete AdminView.vue + four AdminXxxTab.vue files
frontend/src/views/AdminView.vue, frontend/src/components/admin/AdminUsersTab.vue, frontend/src/components/admin/AdminQuotasTab.vue, frontend/src/components/admin/AdminAiConfigTab.vue, frontend/src/components/admin/AuditLogTab.vue
- frontend/src/views/AdminView.vue (confirm it imports the four tab files and no other component imports it post-router-rewire)
- frontend/src/components/admin/__tests__ (list directory — if any tests reference the tab components by path, they must be migrated or deleted alongside)
- frontend/src/router/index.js (after Task 1, confirm no remaining `AdminView.vue` reference)
- All five files are removed via `git rm` (so the deletion is staged for commit).
- No file in `frontend/src/` imports any of the deleted files after this task.
- The build succeeds with the same five `/admin/*` routes resolving via the new view files from 09-02 and 09-03.
Verify with `grep -r "AdminView" frontend/src/ --include='*.vue' --include='*.js'` returns no remaining references (only the legacy file itself before deletion). Verify with `grep -r "AdminUsersTab\\|AdminQuotasTab\\|AdminAiConfigTab\\|AuditLogTab" frontend/src/ --include='*.vue' --include='*.js'` returns only the source files themselves (no consumers). If any consumer reference survives (e.g., a stray test in `frontend/src/components/admin/__tests__/`), stop and report it in the SUMMARY — do not silently leave dead imports. Delete via `git rm frontend/src/views/AdminView.vue frontend/src/components/admin/AdminUsersTab.vue frontend/src/components/admin/AdminQuotasTab.vue frontend/src/components/admin/AdminAiConfigTab.vue frontend/src/components/admin/AuditLogTab.vue`. If `frontend/src/components/admin/__tests__/` contains test files that import any of the deleted tab components, update those tests to import the corresponding new view files (e.g., `AdminUsersTab.vue` → `views/admin/AdminUsersView.vue`); if the tests are obsolete because they test behavior that the routing rearchitecture changes, `git rm` them too and note in SUMMARY.
cd frontend && [ ! -f src/views/AdminView.vue ] && [ ! -f src/components/admin/AdminUsersTab.vue ] && [ ! -f src/components/admin/AdminQuotasTab.vue ] && [ ! -f src/components/admin/AdminAiConfigTab.vue ] && [ ! -f src/components/admin/AuditLogTab.vue ] && ! grep -r "AdminUsersTab\\|AdminQuotasTab\\|AdminAiConfigTab\\|AuditLogTab\\|views/AdminView" src/ --include='*.vue' --include='*.js' && npm run build
- None of the five files exists in the working tree: `[ ! -f frontend/src/views/AdminView.vue ]` etc. all return 0.
- `grep -rE "AdminUsersTab|AdminQuotasTab|AdminAiConfigTab|AuditLogTab|views/AdminView" frontend/src/ --include='*.vue' --include='*.js'` returns no match.
- `cd frontend && npm run build` succeeds.
- `git status -s` shows the five files as `D` (deleted, staged for commit).
- Manual smoke check (recorded in SUMMARY): navigating `/admin`, `/admin/users`, `/admin/quotas`, `/admin/ai`, `/admin/audit` after `npm run dev` mounts the correct view with the admin sidebar.
Dead files deleted; build green; no orphan references; the five admin URLs resolve to the new views.
<threat_model>
Trust Boundaries
Boundary
Description
Browser address bar → router beforeEach guard
Untrusted URL crosses; guard decides whether to mount admin chrome
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-09-04-01
Elevation of Privilege
beforeEach guard
mitigate
to.matched.some(r => r.meta.requiresAdmin) covers all child routes; backend get_current_admin deps on /api/admin/* are the authoritative second gate; manual smoke test in Task 3 verifies non-admin → /admin/users redirects to /
T-09-04-02
Privilege Escalation
LoginView ?redirect= query param
mitigate
D-08 puts role check FIRST: even if a malicious link sends ?redirect=/, admins are immediately redirected to / then to /admin by the D-09 guard branch; the guard is the final authority — frontend redirect manipulation cannot grant access to the wrong area
T-09-04-03
Information Disclosure
Vite production build purging dynamic classes
mitigate
Safelist (CODE-06) ensures every dynamic class family used by formatters.js + actionTypeClass() survives the build; manual visual check of OneDrive provider badge and audit action badges post-build is recorded in SUMMARY
T-09-04-04
Denial-of-Service (Redirect loop)
D-09 admin redirect
mitigate
Guard structure (refresh-await BEFORE both checks) ensures authStore.user is populated before the admin-redirect branch evaluates; isAdminRoute for /admin/* short-circuits the D-09 branch so admins on /admin are never redirected back to /admin; matches RESEARCH.md Pitfall 3 fix
</threat_model>
- `cd frontend && npm run build` → succeeds with safelist applied.
- `grep -rE "AdminUsersTab|AdminQuotasTab|AdminAiConfigTab|AuditLogTab|views/AdminView" frontend/src/ --include='*.vue' --include='*.js'` → no match.
- `grep -c "to.matched.some" frontend/src/router/index.js` ≥ 1; `grep -c "to.meta.requiresAdmin" frontend/src/router/index.js` = 0.
- Manual smoke (live dev server) — recorded in SUMMARY: `/admin/users` reachable as admin; redirects to `/` as non-admin; admin login lands at `/admin`; non-admin login lands at `/` (or `?redirect=` target).
<success_criteria>
ADMIN-08: AdminLayout is the /admin route component; AdminView.vue deleted.
ADMIN-10: Direct navigation to /admin/users, /admin/quotas, /admin/ai, /admin/audit mounts the correct view with the admin sidebar.
ADMIN-12: Guard uses to.matched.some(r => r.meta.requiresAdmin); non-admin redirected from any /admin/* child.
CODE-06: Tailwind safelist includes every dynamic color family used by formatters.js and AuditLogTab.actionTypeClass(), including sky (OneDrive) and amber (audit badges).
D-08/D-09/D-10 strict admin separation enforced.
Five legacy files removed.
</success_criteria>
Create `.planning/phases/09-admin-panel-rearchitecture/09-04-SUMMARY.md` when done. Include: confirmation of all five `/admin/*` URLs resolving correctly under `npm run dev`, the list of deleted files, screenshot or note that OneDrive (`sky`) and audit badges (`amber`) render in production color, and any test-file migrations performed in Task 3.