Roles & Access Control
Implementation Progress
| Feature | Status |
|---|---|
| RESPONDENT role in enum | ✅ Done |
| Auto-enroll creates email_only user with RESPONDENT role | ✅ Done |
| Account upgrade (email_only → full account) | ✅ Done |
| Dashboard live counts (no stale stats) | ✅ Done |
| Quick-login user switcher on localhost | ✅ Done |
| Comprehensive seed data (6 users, 2 orgs, 4 surveys, 9 responses) | ✅ Done |
Backend: GET /dashboard/stats returns role-aware data |
✅ Done |
| Backend: Remove MEMBER from Role enum, migrate to RESPONDENT | ✅ Done |
| Frontend: Respondent dashboard (simplified, surveys-only view) | ✅ Done |
| Frontend: NavBar hides admin links for respondents | ✅ Done |
| Frontend: Route guarding (block admin routes for respondents) | ✅ Done |
| Frontend: Org detail page hides Members/Invitations tabs for respondents | ✅ Done |
Overview
Impact Pulse has two distinct user experiences sharing the same login:
- Admin side (Owner, Admin): Create surveys, manage organizations, view compliance, send reminders, see all responses and impact data
- Respondent side: Take surveys they are assigned to, see their own results and progress over time
A respondent should never see the survey builder, member lists, compliance views, or aggregate impact reports. They should only see the surveys they have taken or are assigned to, plus the organization name they belong to.
Roles
There are exactly three roles in an organization:
| Role | Purpose | What they see |
|---|---|---|
| OWNER | Created the organization. Full control. One per org. | Full admin dashboard, all surveys, members, compliance, settings, impact reports |
| ADMIN | Invited by owner to help manage. Same as owner minus deleting the org. | Same as owner |
| RESPONDENT | Takes surveys. Either invited by admin or self-enrolled via public link. | Their surveys only, their results only, their progress over time, organization name |
Remove MEMBER Role
The MEMBER role is removed from the enum. There is no middle ground between admin and respondent. Any existing MEMBER records in organization_members must be treated as RESPONDENT.
# backend/app/models/enums.py
class Role(str, Enum):
ADMIN = "ADMIN"
RESPONDENT = "RESPONDENT"
# OWNER is not in the enum — it is set once at org creation
# and checked by string comparison. Keep OWNER handling as-is.
Wait: OWNER is already in the enum. Keep OWNER, ADMIN, RESPONDENT. Just remove MEMBER.
What Each Role Sees
Owner / Admin Dashboard
This is the current dashboard. No changes needed.
┌──────────────────────────────────────────────────────────┐
│ ImpactPulse Organizations [user] [notif] [out] │
├──────────────────────────────────────────────────────────┤
│ Welcome back, Chip │
│ │
│ [Create Survey] [Impact Report] [Templates] │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Active │ │Respon- │ │Members │ │Avg │ │
│ │Surveys │ │ses │ │ │ │Impact │ │
│ │ 4 │ │ 9 │ │ 6 │ │ 3.8 │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ Recent Surveys │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Maslow Needs Assessment PUBLISHED View Resp. │ │
│ │ Program Feedback Survey PUBLISHED View Resp. │ │
│ │ Volunteer Satisfaction DRAFT Edit │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ My Organizations View All │
│ CatalyzeUp Nonprofit 6 members OWNER │
│ Youth Futures Initiative 2 members OWNER │
└──────────────────────────────────────────────────────────┘
NavBar for Owner/Admin:
- Logo (links to dashboard)
- "Organizations" link
- User name, notification bell, Sign Out
Respondent Dashboard
A completely different layout. No stat cards, no Create Survey, no Impact Report, no Templates.
┌──────────────────────────────────────────────────────────┐
│ ImpactPulse [user] [notif] [out] │
├──────────────────────────────────────────────────────────┤
│ Welcome back, Maria │
│ │
│ Your Surveys │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Maslow Needs Assessment │ │
│ │ CatalyzeUp Nonprofit │ │
│ │ Last taken: Mar 21, 2026 │ │
│ │ Score: 4.0 (+2.0 since first attempt) │ │
│ │ Next due: Apr 20, 2026 │ │
│ │ [View Results] │ │
│ └────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Program Feedback Survey │ │
│ │ CatalyzeUp Nonprofit │ │
│ │ Last taken: Mar 26, 2026 │ │
│ │ Score: 4.0 (+1.0 since first attempt) │ │
│ │ [View Results] │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ Your Organization │
│ CatalyzeUp Nonprofit │
└──────────────────────────────────────────────────────────┘
Each survey card shows:
- Survey title
- Organization name
- Last taken date (or "Not yet taken" with a [Take Survey] button)
- Their score from last attempt
- Delta from first attempt (if multiple attempts exist)
- Next due date (if cooldown-based recurrence is active)
- [View Results] links to a page showing their own response history
- [Take Survey] or [Retake] button (only if cooldown has expired or never taken)
NavBar for Respondent:
- Logo (links to dashboard)
- NO "Organizations" link (they can see their org on the dashboard)
- User name, notification bell, Sign Out
Implementation
Step 1: Backend — Role-Aware Dashboard Endpoint
Modify GET /api/v1/dashboard/stats to check the user's role.
# backend/app/api/dashboard/routes.py
@router.get("/stats")
async def get_dashboard_stats(current_user = Depends(get_current_active_user)):
db = get_database()
memberships = []
async for m in db.organization_members.find({"user_id": current_user.id}):
memberships.append(m)
roles = [m["role"] for m in memberships]
is_admin = any(r in ("OWNER", "ADMIN") for r in roles)
if is_admin:
# Return the current full admin dashboard data (unchanged)
return { ... existing response ... }
else:
# Respondent: return only their surveys and results
return await _respondent_dashboard(current_user, memberships, db)
The _respondent_dashboard function returns:
async def _respondent_dashboard(user, memberships, db):
org_ids = [m["organization_id"] for m in memberships]
# Get org names
orgs = []
async for org in db.organizations.find({"id": {"$in": org_ids}}):
orgs.append({"id": org["id"], "name": org["name"]})
# Get surveys the respondent has taken OR is assigned to
my_responses = []
async for r in db.survey_responses.find({"respondent_email": user.email}):
my_responses.append(r)
my_assignments = []
async for a in db.survey_assignments.find({"member_id": user.id}):
my_assignments.append(a)
# Collect unique survey IDs
survey_ids = set()
for r in my_responses:
survey_ids.add(r["survey_id"])
for a in my_assignments:
survey_ids.add(a["survey_id"])
# Build survey cards
my_surveys = []
for sid in survey_ids:
survey = await db.surveys.find_one({"id": sid})
if not survey:
continue
# Get this user's responses for this survey
user_responses = [r for r in my_responses if r["survey_id"] == sid]
user_responses.sort(key=lambda x: x.get("submitted_at", datetime.min))
attempts = len(user_responses)
latest = user_responses[-1] if attempts > 0 else None
baseline = user_responses[0] if attempts > 0 else None
latest_score = latest.get("score") if latest else None
baseline_score = baseline.get("score") if baseline else None
delta = round(latest_score - baseline_score, 2) if (
latest_score is not None and baseline_score is not None and attempts > 1
) else None
last_taken = latest["submitted_at"].isoformat() if latest else None
# Next due (from cooldown)
min_days = survey.get("min_days_between_attempts", 30)
next_due = None
if latest and min_days > 0:
next_due = (latest["submitted_at"] + timedelta(days=min_days)).isoformat()
# Can they take it now?
can_take = True
if latest and min_days > 0:
can_take = datetime.utcnow() >= latest["submitted_at"] + timedelta(days=min_days)
my_surveys.append({
"id": survey["id"],
"public_id": survey.get("public_id"),
"title": survey["title"],
"organization_name": next(
(o["name"] for o in orgs if o["id"] == survey.get("organization_id")), ""
),
"attempts": attempts,
"latest_score": latest_score,
"delta": delta,
"last_taken": last_taken,
"next_due": next_due,
"can_take": can_take,
})
return {
"is_respondent": True,
"my_surveys": my_surveys,
"organizations": orgs,
}
Key: The response includes "is_respondent": True so the frontend knows which dashboard to render.
Step 2: Backend — Remove MEMBER Role
- In
backend/app/models/enums.py: removeMEMBER = "MEMBER"from theRoleenum - In
backend/app/api/organizations/routes.py: change the default invite role from"MEMBER"to"RESPONDENT" - In
backend/app/api/auth/routes.py: the auto-enroll already usesRole.RESPONDENT(correct) - Anywhere that checks
role == "MEMBER": treat it as"RESPONDENT"
Step 3: Frontend — Detect Role and Branch UI
The dashboard endpoint now returns either the admin format (with stats, recent_surveys, organizations) or the respondent format (with is_respondent: true, my_surveys, organizations).
// frontend/src/pages/DashboardPage.tsx
const DashboardPage: React.FC = () => {
const [data, setData] = useState<any>(null)
useEffect(() => {
// Fetch dashboard stats
const res = await fetch(...)
const d = await res.json()
setData(d)
}, [])
if (!data) return <Loading />
// Branch based on role
if (data.is_respondent) {
return <RespondentDashboard data={data} />
}
return <AdminDashboard data={data} />
}
Step 4: Frontend — RespondentDashboard Component
A new component that renders the simplified respondent view. Shows:
- "Your Surveys" header (no action buttons)
- List of survey cards with: title, org name, last taken, score, delta, next due, take/retake button
- "Your Organization" section showing org name(s)
Step 5: Frontend — NavBar Role Awareness
The NavBar needs to know the user's role. The simplest approach:
- The dashboard fetch already tells us
is_respondent. Store this in AuthContext or a separate state. - Or: add
rolesto theGET /auth/meresponse (list of{org_id, role}objects).
Then in NavBar.tsx:
// Hide "Organizations" link for respondents
{user && !isRespondent && (
<Link to="/organizations" ...>Organizations</Link>
)}
Step 6: Frontend — Route Guarding for Admin Routes
Add an AdminRoute wrapper (like ProtectedRoute but also checks role):
const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isAdmin } = useAuth()
if (!user) return <Navigate to="/auth/signin" />
if (!isAdmin) return <Navigate to="/dashboard" />
return <>{children}</>
}
Routes that use AdminRoute instead of ProtectedRoute:
/organizations/new(Create Organization)/surveys/builderand/surveys/builder/:id(Survey Builder)/surveys/create(Create Survey)/surveys/:id/edit(Edit Survey)/surveys/:id/responses(View Responses)/surveys/:id/impact(Impact Report)/organizations/:orgId/surveys/:surveyId/compliance(Compliance)/surveys/templates(Templates)
Routes that stay as ProtectedRoute (accessible to respondents):
/dashboard/organizations(list view, respondents see their orgs)/organizations/:id(detail, but respondents see limited view)/settings/notifications/survey/:publicId(take survey, already public)
Step 7: Frontend — Org Detail for Respondents
When a respondent views /organizations/:id, hide:
- Members tab
- Pending Invitations tab
- "Invite Member" button
- Survey management actions (edit, delete)
Show only:
- Organization name and description
- Surveys tab (but only surveys the respondent has taken or is assigned to)
- Their response history per survey
Testing
Seed Data for Testing
The seed data already has all the users needed:
| User | Role | Password |
|---|---|---|
| chip@test.com | OWNER | testing123 |
| admin2@test.com | ADMIN | testing123 |
| maria.santos@example.com | RESPONDENT | testing123 |
| james.wilson@example.com | RESPONDENT | testing123 |
| priya.kumar@example.com | RESPONDENT | testing123 |
Quick-login buttons on localhost make it easy to switch between users.
What to Verify
- Log in as Chip (Owner): See full dashboard with stats, Create Survey, Impact Report, Templates, all surveys, org management
- Log in as Maria (Respondent): See only "Your Surveys" with Maslow and Program Feedback. See scores and deltas. No Create Survey, no Impact Report, no members list. Can click View Results. Cannot access
/surveys/builder,/organizations/new, or compliance pages. - Log in as Priya (Respondent): See surveys from both orgs (CatalyzeUp and Youth Futures). See in-progress Maslow session.
- Log in as anon.taker (email-only): Cannot log in (no password). This user only exists to test the email-only flow.
Backend Tests to Add
# Test that respondent dashboard only returns their surveys
async def test_respondent_dashboard_shows_only_own_surveys():
# Login as maria
# Call GET /dashboard/stats
# Assert is_respondent is True
# Assert my_surveys contains only surveys Maria has taken
# Assert no stats, recent_surveys keys
# Test that respondent cannot access admin endpoints
async def test_respondent_cannot_create_survey():
# Login as maria
# POST /surveys -> 403
# Test that respondent can take a survey they are assigned to
async def test_respondent_can_take_assigned_survey():
# Login as maria
# POST /surveys/{public_id}/respond -> 201