docs: extend §D — context-aware default dashboard + standardized role switcher
After review, two additions to the multi-role UX section: 1. Replace static-priority post-login redirect with context-aware "go where the work is" via new user.getDefaultDashboard() — a juror+observer landing during an active jury round goes to /jury even though observer has no work; falls back to static priority when no role has actionable work. 2. Standardize the role switcher's location across all dashboards. Extract shared useRoleSwitcher hook + new RoleSwitcherPill that renders in the top-right of every layout, including admin (which currently puts switching in the bottom-left sidebar pill). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -212,27 +212,55 @@ A standalone test PR is *not* planned — tests ride with the change they cover.
|
||||
- `src/lib/email/templates/mentor-onboarding.tsx` (new)
|
||||
- `src/server/services/notifications.ts` (or equivalent — call site to send mentor-onboarding email when MENTOR role is freshly added to a user)
|
||||
|
||||
**1. Post-login redirect — priority-based on `roles[]`:**
|
||||
**1. Post-login redirect — context-aware "go where the work is":**
|
||||
|
||||
Replace single-`role` switch in `src/app/page.tsx` with priority order:
|
||||
Replace single-`role` switch in `src/app/page.tsx` with a priority list that is *filtered by actionable work*. The user lands on the highest-priority role for which they have something to do right now; if no role has active work, fall back to the static priority order.
|
||||
|
||||
New tRPC query: `user.getDefaultDashboard()` (server-side, called from `src/app/page.tsx`):
|
||||
|
||||
```ts
|
||||
// Static priority — used as fallback ordering AND as the order we check for work.
|
||||
const ROLE_DASHBOARD_PRIORITY: Array<[UserRole, Route]> = [
|
||||
['SUPER_ADMIN', '/admin'],
|
||||
['PROGRAM_ADMIN', '/admin'],
|
||||
['AWARD_MASTER', '/award-master'],
|
||||
['JURY_MEMBER', '/jury'],
|
||||
['MENTOR', '/mentor'],
|
||||
['OBSERVER', '/observer'],
|
||||
['APPLICANT', '/applicant'],
|
||||
['OBSERVER', '/observer'],
|
||||
['AUDIENCE', '/audience'],
|
||||
]
|
||||
const userRoles = session.user.roles ?? [session.user.role]
|
||||
const target = ROLE_DASHBOARD_PRIORITY.find(([role]) => userRoles.includes(role))?.[1]
|
||||
if (target) redirect(target)
|
||||
```
|
||||
|
||||
**Decision: priority-based, not "remember last view".** The "remember last view" approach requires a new column on User and adds login-side complexity. Priority is deterministic, easy to explain, and the role-switcher dropdown handles the case where the user wants a different view. Revisit if users complain.
|
||||
For each role the user holds (in priority order), the server checks "does this user have actionable work in this role right now?":
|
||||
|
||||
| Role | "Has actionable work" predicate |
|
||||
|------|---------------------------------|
|
||||
| SUPER_ADMIN / PROGRAM_ADMIN | Always true (admin work is always present) |
|
||||
| AWARD_MASTER | Any unfinalized award decision in an active round in current edition |
|
||||
| JURY_MEMBER | Any `JuryAssignment` linked to a round whose `status = ROUND_ACTIVE` AND the user has at least one PENDING evaluation |
|
||||
| MENTOR | Any `MentorAssignment` whose linked round is `ROUND_ACTIVE` AND `workspaceEnabled = true` |
|
||||
| APPLICANT | Any `Project` led by user with at least one `ProjectRoundState` in a non-terminal state in an active round |
|
||||
| OBSERVER | Always false (observers have nothing to act on) |
|
||||
| AUDIENCE | Always false |
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. Try roles in priority order. Return the first role whose predicate is true.
|
||||
2. If no role has actionable work, return the highest-priority role the user holds (static fallback).
|
||||
3. Always end with a non-null route (worst case: any signed-in user has at least their primary role).
|
||||
|
||||
**Why this matters (your example):** a juror+observer who logs in during an open jury round lands on `/jury` (because they have a pending evaluation), not `/observer`. A mentor+juror logs in during an active MENTORING round → `/mentor`. After both rounds close, same user logs in → static fallback (jury > mentor) → `/jury`. The role switcher in the user menu is always available to override.
|
||||
|
||||
**Decision: context-aware, not "remember last view".** "Remember last view" requires a new column and surprises users when their last context disappears (round closed, role removed). Context-aware is deterministic, explains itself, and handles the cross-role overlap cleanly. The role switcher dropdown is the user's escape hatch.
|
||||
|
||||
**Tests** (in PR 6):
|
||||
- Juror with pending evaluation in active round + Observer → `/jury`
|
||||
- Juror with no active assignments + Observer → `/jury` (fallback to static priority)
|
||||
- Mentor+Juror, MENTORING round active, no jury work → `/mentor`
|
||||
- Mentor+Juror, both rounds active with work in both → `/jury` (priority order breaks the tie)
|
||||
- Observer-only user → `/observer`
|
||||
- Multi-role with no active work anywhere → static-priority fallback
|
||||
|
||||
**2. Bulk juror→mentor promotion** on `/admin/members`:
|
||||
- Add row checkboxes to the Members table (already a table — confirm during impl).
|
||||
@@ -254,11 +282,42 @@ if (target) redirect(target)
|
||||
**5. Banner shows all roles:**
|
||||
- When `session.user.roles.length > 1`, render comma-separated list: "Impersonating Dr. Sophie Laurent (JURY MEMBER, MENTOR)".
|
||||
|
||||
**6. Standardize the role-switcher (location + presentation):**
|
||||
|
||||
Today's state:
|
||||
- Header layouts (`role-nav.tsx`) — used by jury, mentor, applicant, observer, award-master — put the user menu **top-right** with role-switcher items inside the dropdown.
|
||||
- Admin layout (`admin-sidebar.tsx`) puts the user menu **bottom-left of the sidebar** with its own duplicate `ROLE_SWITCH_OPTIONS` constant + `switchableRoles` filter (lines 161, 191, 377-401).
|
||||
|
||||
Two problems: (a) duplicated logic across two files; (b) different physical placement, so a multi-role user has to learn two patterns to find "Switch View".
|
||||
|
||||
Changes:
|
||||
|
||||
- **Extract a shared module** at `src/components/layouts/role-switcher.tsx` exporting:
|
||||
- `useRoleSwitcher()` hook returning `{ switchableRoles: Array<{ label, path, icon }>, currentBasePath }`. Both `role-nav.tsx` and `admin-sidebar.tsx` import this. Source of truth for `ROLE_SWITCH_OPTIONS` lives here only.
|
||||
- `RoleSwitcherMenuItems` component — renders the dropdown items (used inside both layouts' user menus). Keeps rendering inline-consistent.
|
||||
- `RoleSwitcherPill` component — a standalone visible button that renders just outside the user-menu dropdown, with label "Switch View" + the icon of the next-best alternate role. Visible only when `switchableRoles.length > 0`. Click opens a small popover listing alternates.
|
||||
|
||||
- **Place the `RoleSwitcherPill` in a consistent location across all layouts**: top-right of the header, immediately to the LEFT of the notifications bell. For the admin layout (sidebar-based), add a top-right header strip that hosts the pill + notifications + theme toggle, mirroring the other dashboards. (The admin sidebar keeps everything else; just the top-bar is added.)
|
||||
|
||||
Why top-right: that's where the existing role-nav layouts already put switching/profile actions. Admins gain the pill in the same spot — no learning curve when switching from /admin to /jury.
|
||||
|
||||
- **Pill behavior:**
|
||||
- Hidden if `switchableRoles.length === 0` (single-role users see nothing — clean default).
|
||||
- Hidden when `isImpersonating` (impersonator UX is already different; the existing impersonation banner with "Return to Admin" handles role-switching for that path).
|
||||
- On hover/focus: shows tooltip "Switch dashboard view".
|
||||
- Keyboard: `Cmd+Shift+V` shortcut opens the popover (nice-to-have; ship if it doesn't add much code).
|
||||
|
||||
- **Admin sidebar bottom user pill stays** (so admin users can still sign out / open settings from there). The role-switcher items are removed from that menu — they live exclusively in the new pill + the user-dropdown's switch list. (Avoids three places to switch view.)
|
||||
|
||||
**Acceptance for §D.6:** any signed-in user with `roles.length > 1` sees a "Switch View" pill in the same screen position regardless of which dashboard they're currently in.
|
||||
|
||||
**Tests** (in PR 6):
|
||||
- Post-login redirect honors priority for multi-role user.
|
||||
- `user.getDefaultDashboard` test cases enumerated above (juror+observer with active jury round → /jury; etc.).
|
||||
- `bulkUpdateRoles` adds MENTOR to N users and sends N onboarding emails.
|
||||
- Idempotency: second `bulkUpdateRoles` with same input does NOT resend email.
|
||||
- Impersonation banner does not intercept clicks on user dropdown (Playwright e2e if available).
|
||||
- `RoleSwitcherPill` renders in the top-right of every dashboard for multi-role users; renders nothing for single-role users.
|
||||
- Single shared `useRoleSwitcher` source means changing `ROLE_SWITCH_OPTIONS` updates both layouts simultaneously.
|
||||
|
||||
---
|
||||
|
||||
@@ -452,7 +511,10 @@ Each PR independently revertable. PRs 1, 2, 4 ship with no migration → straigh
|
||||
- `/admin/mentors` shows real list of MENTOR-role users with current assignments.
|
||||
|
||||
**PR 6 (§D + §F.2):**
|
||||
- Multi-role user (jury+mentor) lands on `/jury` after login (priority order). Role switcher dropdown shows "Mentor View".
|
||||
- Juror+observer logging in during an active jury round lands on `/jury` (context-aware default). Same user logging in after the round closes lands on `/jury` via static fallback (still highest-priority role they hold).
|
||||
- Mentor+juror with active mentoring assignments and no jury work lands on `/mentor`.
|
||||
- `RoleSwitcherPill` ("Switch View") renders in the top-right of the header on every dashboard for multi-role users, in the same screen position regardless of layout. Single-role users don't see it.
|
||||
- Admin sidebar still has the user pill at the bottom-left for sign-out / settings; role-switcher entries are removed from that menu (live in the new pill instead).
|
||||
- `/admin/members` allows multi-select + "Add MENTOR role to selected" → all selected users get email + role.
|
||||
- Impersonation banner doesn't intercept clicks on the user dropdown.
|
||||
- Mentor `/mentor` dashboard shows "Recent Messages" card; applicant `/applicant` dashboard shows "Conversation with [Mentor]" card.
|
||||
|
||||
Reference in New Issue
Block a user