CatalyzeUpDocs
impact pulse / product

Roles & Access Control

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:

  1. Admin side (Owner, Admin): Create surveys, manage organizations, view compliance, send reminders, see all responses and impact data
  2. 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

  1. In backend/app/models/enums.py: remove MEMBER = "MEMBER" from the Role enum
  2. In backend/app/api/organizations/routes.py: change the default invite role from "MEMBER" to "RESPONDENT"
  3. In backend/app/api/auth/routes.py: the auto-enroll already uses Role.RESPONDENT (correct)
  4. 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:

  1. The dashboard fetch already tells us is_respondent. Store this in AuthContext or a separate state.
  2. Or: add roles to the GET /auth/me response (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/builder and /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

  1. Log in as Chip (Owner): See full dashboard with stats, Create Survey, Impact Report, Templates, all surveys, org management
  2. 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.
  3. Log in as Priya (Respondent): See surveys from both orgs (CatalyzeUp and Youth Futures). See in-progress Maslow session.
  4. 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