Skip to main content

SP‑D — People & Access Admin Page — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Consolidate all member/people admin (roster + roles/owner + consents + invites + member↔account linking + child-approvals + add-adult) into ONE two-tab People & Access admin page; turn the main-shell Household tab into a read-only family view; add an admin Households management tab (join/create) while keeping fast switching universal on More.

Architecture: Rebuild the existing GovernancePage (route GovernanceRoute unchanged — no rename, no codegen) into a DsSegmented<PeopleAccessTab> two-tab page (Members / Households), mirroring ManagePage. The page provides BOTH RoleOwnerBloc (roles/owner/consent) and MembersBloc (roster/invites/child-approvals/add) via MultiBlocProviderno bloc merge (see Bloc note). The Household tab (HouseholdPage) keeps MembersBloc but strips the admin affordances from its widget tree → read-only roster + family goals + tailored view.

Tech Stack: Flutter 3.44 / Dart 3.9 (FVM: fvm flutter test for the app). bloc/cubit; auto_route; the design system (DsSegmented, DsSection, DsAppBackdrop); the flow-test harness (flowTest + MocksContainer).

Global Constraints

  • One data path: Bloc → Repository → Client facade → Service → Adapter. Presentation NEVER imports drift/supabase.
  • Typed errors ONLY: on <SpecificException> — never bare catch, never Error. Reuse the existing _runGuarded pattern (maps DomainRuleException→themed failure dialog).
  • No new authorization / no server change: SP‑D is a pure client-side UI/IA consolidation. Roles/consents/invites are already server-authoritative. NO migration, NO RLS/capability change. If a genuine server gap surfaces, STOP and flag it — do not add authz.
  • Route stays GovernanceRoute (path /governance, guards [_mustChangeGuard, AdminGuard]) — do NOT rename it (avoids router.gr.dart regen). Only the page class content + title become "People & Access". (Route-name is a cosmetic follow-up.)
  • Bloc note (deliberate deviation from spec's "single bloc"): the People page co-locates RoleOwnerBloc + MembersBloc via MultiBlocProvider; the Household tab renders its own MembersBloc read-only. A full single-bloc merge is DEFERRED (the two blocs are large — MembersBloc also owns family goals + tailored — and merging is high-risk for no user-facing gain). The duplication is now co-located on one page, not spread across two.
  • FVM only; no brand strings; all copy via Strings; flow tests scoped to EPIC + FEATURE SET (ONE flowTest, multiple stories). Baselines must not drop (app ~838, SDK ~1214).

Reference anchors (verified — implementers re-read these)

  • GovernancePage app/lib/inside/routes/authenticated/governance/page.dart: wrappedRoute (23-31) provides RoleOwnerBloc(..)..add(RoleOwnerStarted()); build (34-183) = DsAppBackdropScaffoldAppBar(Strings.governanceTitle)MultiBlocListener(actionFailure dialog + GovernancePage.consentRecordedSnackbar)→BlocBuilder→ready = Column of DsSection(Strings.governanceTitle) (rows _GovernanceRow 186-250, tap→showRoleOwnerEditorSheet) + _ConsentSection (258-392, GovernancePage.consentChip_<id>/consentButton_<id>).
  • RoleOwnerBloc app/lib/inside/blocs/governance/role_owner_bloc.dart: events RoleOwnerStarted, RoleOwnerRoleSet(memberId, Set<MemberRole>), RoleOwnerOwnerToggled(memberId, grant), RoleOwnerTransferred(fromMemberId, toMemberId), RoleOwnerMemberRemoved(memberId), RoleOwnerConsentCaptured(memberId, ConsentMethod), RoleOwnerConsentRevoked(memberId). State (105-172): status (RoleOwnerStatus.initial/loading/ready/loadFailure/working/actionFailure), members, staleConsentMemberIds, actingMember, errorMessage, consentCaptureOutcome. _runGuarded (389-435). Verbs: watchMembers, checkConsentCurrency, setRole, grantOwner, revokeOwner, transferOwnership, removeMember, captureConsent, revokeConsent.
  • showRoleOwnerEditorSheet(context, {required member}) governance/role_owner_editor_sheet.dart: captures RoleOwnerBloc, re-injects; renders role toggles (RoleOwnerEditor.memberBase/adminToggle/helperToggle, admin/helper disabled when !isLinked), RoleOwnerEditor.linkHint, RoleOwnerEditor.ownerSwitch, RoleOwnerEditor.transferButton (owner only), RoleOwnerEditor.removeButton (danger).
  • MembersBloc app/lib/inside/blocs/household/members_bloc.dart: deps HouseholdRepository, PlacesRepository, GoalsRepository, CurrentMemberRepository, WalletRepository, SelectedMemberRepository?. Admin events used by SP‑D: MembersStarted, MemberEditorOpened({member?}), MemberEditorSaved (+ the editor field events), MemberInviteResent(id), MemberInviteRevoked(id), MemberAccountInviteSent({memberId,email,note?}), MemberAccountInviteResent(id), MemberChildCodeIssued(id), AdminCreateAdultRequested({displayName,kind,email?}), ChildApproved({memberId,method}), ChildDeclined(id), ChildConvertedToAdult(id). State: members, pendingChildren, isParentalViewer, isTailored, accountInviteToken/Attempt, coParentInviteToken/MemberId/Attempt, childAttachCode/Attempt, adminCreateTempPassword/Attempt, editor fields, family-goals fields, tailored fields. Verbs incl. watchMembers, pendingConsentChildren, addMember, updateMember, inviteCoParent, inviteMember, issueChildAttachCode, adminCreateAdult, captureConsent, declineChild, convertPendingChildToAdult, resendInvite, revokeInvite.
  • HouseholdPage app/lib/inside/routes/authenticated/household/page.dart: wrappedRoute (32-47) MembersBloc(6 deps)..add(MembersStarted()). build (50-267): TopLevelHeader(actions: state.isTailored ? [] : [governanceButton, addMemberButton, createAdultButton]) (buttons 178-190+); master body = listChild roster ListView (_MemberTile 270-338 tap→MemberProfileRoute, _InvitedMemberTile 344-384 tap→showInviteActionsSheet) + ChildApprovalsSection(state) (parental only) + FamilyGoalsSection(state); tailored body = _TailoredMemberView + FamilyGoalsSection.
  • ManagePage tab scaffold app/lib/inside/routes/authenticated/manage/page.dart: enum ManageTab {..} (21); wrappedRoute MultiBlocProvider (39-73, lazy:false, seeded); _ManagePageState StatefulWidget ManageTab _tab (79); DsSegmented<ManageTab>(key:'ManagePage.tabs', value:_tab, onChanged: setState, segments:[DsSegment(value,label)]) (108-145); _body() exhaustive switch(_tab) (147-158); DsAppBackdropScaffoldAppBar.
  • AdminHub row admin/page.dart:73-78 _AdminRow(rowKey:'AdminHubPage.rolesOwnersEntry', title:Strings.governanceTitle, onTap:→GovernanceRoute). Household shield household/page.dart:178-190 IconButton(key:'MembersPage.governanceButton', →GovernanceRoute).
  • Routing app/lib/inside/routes/router.dart:287-308: /governance = [_mustChangeGuard, AdminGuard(getCurrentMember)]. AdminGuard.canAccessAdmin(member) = member.owner || can(member, Capability.manageRoles) (guards/admin_guard.dart:38).
  • Child-approval members/child_approvals_section.dart (ChildApprovalsSection({state}), tiles ChildApprovalsSection.tile_<id>showChildApprovalSheet) + members/child_approval_sheet.dart (ChildApprovalSheet.agreeButtonChildApproved/ChildConvertedToAdult if age≥13; ChildApprovalSheet.declineButtonChildDeclined).
  • showInviteActionsSheet(context, {member}) members/invite_actions_sheet.dart: InviteActionsSheet.resendButtonMemberInviteResentshowCoParentCodeSheet; InviteActionsSheet.revokeButtonMemberInviteRevoked.
  • Link/add sheets: members/account_invite_sheet.dart showInviteToAccountSheet(context, {member}); members/child_signup_code_sheet.dart showChildSignupCodeSheet(context, {member}); members/create_adult_sheet.dart showCreateAdultSheet(context); members/member_editor_sheet.dart showMemberEditorSheet(context, {member}); members/co_parent_code_sheet.dart showCoParentCodeSheet(...).
  • Households source: HouseholdRepository.listMyHouseholds(authUserId); the More switcher more/widgets/household_switcher_sheet.dart + more/join_with_code_sheet.dart showJoinWithCodeSheet + SetupRoute(isCreatingAdditional:true). MoreBloc loads listMyHouseholds (resolve authUserId via CurrentMemberRepository.current()?.authUserId, never the lens) — mirror it.
  • Flow-test templates: app/test/flows/governance_test.dart (6 flowTests) + app/test/flows/members_test.dart; MocksContainer app/test/util/mocks/mocked_app.dart. Existing tests to update: governance_test.dart, governance_reconsent_chip_test.dart, members_test.dart, admin_guard_test.dart, widget/governance_guards_test.dart.

Task 1: Scaffold the two-tab People & Access page (Members = existing governance content)

Files:

  • Modify: app/lib/inside/routes/authenticated/governance/page.dart (rebuild into the two-tab page)
  • Modify: app/lib/inside/i18n/strings.dart (peopleAccessTitle, peopleTabMembers, peopleTabHouseholds)
  • Modify: app/lib/inside/routes/authenticated/admin/page.dart (row title → Strings.peopleAccessTitle; still routes to GovernanceRoute)
  • Test: app/test/flows/governance_test.dart (extend — content now under the Members tab)

Interfaces: Produces enum PeopleAccessTab { members, households }; GovernancePageStatefulWidget with a DsSegmented<PeopleAccessTab> + MultiBlocProvider(RoleOwnerBloc + MembersBloc) in wrappedRoute.

  • Step 1: Rebuild GovernancePage scaffold — mirror ManagePage. wrappedRouteMultiBlocProvider(providers: [BlocProvider(create: (_) => RoleOwnerBloc(..)..add(RoleOwnerStarted())), BlocProvider(create: (_) => MembersBloc(..all 6 deps..)..add(MembersStarted()))], child: this). Make it a StatefulWidget with PeopleAccessTab _tab = PeopleAccessTab.members. build: DsAppBackdropScaffoldAppBar(Strings.peopleAccessTitle)Column([Padding(DsSegmented<PeopleAccessTab>(key:'PeoplePage.tabs', value:_tab, onChanged:(t)=>setState(()=>_tab=t), segments:[DsSegment(value:members,label:Strings.peopleTabMembers), DsSegment(value:households,label:Strings.peopleTabHouseholds)])), Expanded(_body())]). _body() exhaustive switch(_tab): membersconst _MembersTab() (move the current MultiBlocListener+BlocBuilder+two DsSections verbatim into _MembersTab, still driven by RoleOwnerBloc); householdsconst _HouseholdsTab() stub (Task 4).

  • Step 2: Add strings + re-point AdminHub rowpeopleAccessTitle='People & Access', peopleTabMembers='Members', peopleTabHouseholds='Households'. admin/page.dart:75 title → Strings.peopleAccessTitle (onTap unchanged → GovernanceRoute).

  • Step 3: Test — extend governance_test.dart: page renders DsSegmented(PeoplePage.tabs), defaults to Members, existing member/consent rows render under Members. Adjust existing flowTests to the tabbed structure. cd app && fvm flutter test test/flows/governance_test.dart → then full suite; fix scaffold breakage.

  • Step 4: Commitfeat(sp-d): People & Access two-tab page scaffold (Members = governance content)


Files:

  • Modify: app/lib/inside/routes/authenticated/governance/role_owner_editor_sheet.dart
  • Test: app/test/flows/governance_test.dart (or a new people_members_test.dart)

Design: the per-member sheet (showRoleOwnerEditorSheet) gains the member↔account LINK actions currently on the Household-tab member editor: unlinked adult → Invite to account (showInviteToAccountSheet); unlinked child → Generate sign-up code (showChildSignupCodeSheet). Those are MembersBloc flows (co-located on the page), so capture the MembersBloc before opening them (they re-inject it).

  • Step 1: Extend the sheet — below role/owner/remove: if member.kind.isAdult && member.authUserId == null && member.status != MemberStatus.invitedDsButton(key:'RoleOwnerEditor.inviteAccountButton')Navigator.pop then showInviteToAccountSheet(context, member: member); if member.kind == MemberKind.child && member.authUserId == nullDsButton(key:'RoleOwnerEditor.childCodeButton')showChildSignupCodeSheet(context, member: member). Capture context.read<MembersBloc>() before popping (mirror the Household-tab editor's capture pattern).

  • Step 2: Test — unlinked adult → invite-account button shows + opens the sheet; unlinked child → child-code button; linked member → neither (roles enabled). RED (buttons absent) → implement → GREEN. Full suite.

  • Step 3: Commitfeat(sp-d): per-member sheet folds in account-link + child-code actions


Task 3: Members tab — Add actions + pending queue (moved off the Household tab)

Files:

  • Modify: app/lib/inside/routes/authenticated/governance/page.dart (_MembersTab header + pending section)
  • Modify: app/lib/inside/i18n/strings.dart (peoplePendingTitle, add-button labels)
  • Test: people_members_test.dart

Design: the Members tab gets (a) an Add header — add-adult (showCreateAdultSheet, admin-gated), add-child (showMemberEditorSheet(member:null)), invite-co-parent (showMemberEditorSheet co-parent add-mode); and (b) a Pending section (with counts): child-approvals (ChildApprovalsSection(state: membersState), reused) + pending co-parent invites (_InvitedMemberTile-equivalent → showInviteActionsSheet). All from the co-located MembersBloc.

  • Step 1: Add header Add actions — a row of DsButtons (PeoplePage.addAdult/PeoplePage.addChild); gate add-adult by AdminGuard.canAccessAdmin(currentMember) (mirror household/page.dart:178-190). Wire to the existing sheets.

  • Step 2: Pending sectionDsSection(Strings.peoplePendingTitle) shown when membersState.isParentalViewer && (pendingChildren.isNotEmpty || invitedMembers.isNotEmpty): ChildApprovalsSection(state: membersState) + invited co-parent tiles → showInviteActionsSheet. Show a count.

  • Step 3: Test — add-adult present for admin / absent for non-admin; pending shows a seeded pendingConsent child + invited member; tapping the child → approval sheet → ChildApproved. RED → implement → GREEN. Full suite.

  • Step 4: Commitfeat(sp-d): Members tab add-actions + pending (child-approvals + invites) queue


Task 4: Households tab

Files:

  • Create: app/lib/inside/blocs/people/households_cubit.dart (+ state)

  • Modify: app/lib/inside/routes/authenticated/governance/page.dart (_HouseholdsTab)

  • Modify: app/lib/inside/blocs/join_household/{cubit,state}.dart (already_member switch-offer — conditional)

  • Modify: app/lib/inside/i18n/strings.dart

  • Test: app/test/unit/blocs/people_households_cubit_test.dart + people_households_test.dart

  • Step 1: PeopleHouseholdsCubitload() resolves authUserId = (await _currentMemberRepository.current())?.authUserId; households = await _householdRepository.listMyHouseholds(authUserId); active id from the current household (getHousehold()?.id) — do NOT add a server verb. State: households, activeHouseholdId, status (loading/ready/error). Typed errors (on Exception → error; never bare). Test (list, active-mark, empty, error) RED→GREEN.

  • Step 2: _HouseholdsTabBlocProvider(create: (_) => PeopleHouseholdsCubit(..)..load()); render each household (PeoplePage.household_<id>, active check) + "Join a household" (PeoplePage.joinHouseholdshowJoinWithCodeSheet(context)) + "Create another" (PeoplePage.createHouseholdrouter.push(SetupRoute(isCreatingAdditional:true))). Strings peopleHouseholdsJoin/peopleHouseholdsCreate.

  • Step 3: already_member switch-offer (deferred SP‑B fold-in — CONDITIONAL) — ONLY if peek_invite's already_member household id already reaches the app cheaply: in JoinHouseholdCubit, on InviteAcceptException(alreadyMember) emit JoinAlreadyMember(householdId, householdName) (peek returns the household); the sheet shows a "Switch to it" button → setActiveHousehold + replaceAll(MainShell). If the exception does not carry the household id, KEEP the plain JoinFailure(alreadyMember) and mark this deferred — do NOT widen the SDK exception/RPC just for this. Test whichever path is built.

  • Step 4: Test + commit — households list + join/create routing; already_member path (switch-offer OR documented-deferred). feat(sp-d): Households management tab (list + join/create)


Task 5: Household tab → read-only family view

Files:

  • Modify: app/lib/inside/routes/authenticated/household/page.dart

  • Test: app/test/flows/members_test.dart (update — admin gone)

  • Step 1: Strip admin from TopLevelHeader — remove the three action buttons (MembersPage.governanceButton, MembersPage.addMemberButton, MembersPage.createAdultButton) from the master-view actions: (→ actions: const [] or non-admin only). The roster _MemberTile still taps to MemberProfileRoute (read-only, unchanged).

  • Step 2: Remove ChildApprovalsSection from the master body (moved to People, Task 3). KEEP listChild roster + FamilyGoalsSection + the tailored branch (_TailoredMemberView + FamilyGoalsSection) exactly.

  • Step 3: Test — admin master view: governance/add/create buttons ABSENT; roster + family goals render; member tap → MemberProfileRoute; ChildApprovalsSection absent. Tailored (child) view unchanged. Update members_test.dart stories that drove admin via the Household tab (assert absence; admin now via People page). RED → implement → GREEN.

  • Step 4: Commitfeat(sp-d): Household tab is a read-only family view (admin moved to People & Access)


Task 6: Feature-set flow test + existing-test fallout + full green

Files:

  • Create: app/test/flows/people_access_test.dart
  • Modify: existing governance_test.dart / members_test.dart / admin_guard_test.dart / governance_guards_test.dart stories assuming the OLD structure

CRITICAL — existing-test fallout (SP‑B lesson): moving admin off the Household tab + tabbing the governance page WILL break tests that drove admin from the Household-tab header or asserted the flat governance page. These are SP‑D consequences, NOT pre-existing — repoint them here. Do NOT call any failure "pre-existing" without proving it failed at the SP‑D base commit.

  • Step 1: people_access_test.dart — ONE flowTest('people_access', …) under one EPIC; stories as screenshot segments: manage-a-member (People → Members → member sheet → toggle admin + owner + capture consent), add-adult (Members → Add adult → temp-password result), switch-and-join (Households tab → list + Join). Model on governance_test.dart + household_switch_test.dart; stub MocksContainer (watchMembers, pendingConsentChildren, setRole, grantOwner, captureConsent, adminCreateAdult, listMyHouseholds).

  • Step 2: Repoint fallout — full suite; for each failure prove it's an SP‑D consequence, then repoint (governance assertions → Members tab; Household-tab admin stories → People page / assert-absent). Keep intent; don't weaken.

  • Step 3: HARD GATEcd app && fvm flutter test0 failures, passed not dropped vs ~838. Report the exact +N ~M -K line.

  • Step 4: Committest(sp-d): people-access feature-set flowTest + repoint governance/members fallout


Post-plan

  • graphify update . + commit (chore: graphify update after SP-D).
  • Whole-branch SP‑D review (opus) — focus: no lost admin capability (every action from the Household tab / flat governance page is reachable on the People page + still authz-gated), typed errors, no orphaned entry points, the read-only Household tab has truly no admin affordance, test fallout genuinely repointed.
  • DEPLOY (owner pre-authorized pattern): SP‑D is client-only (NO migration). Push → redeploy both URLs → rebuild the Android APK. (No prod migration; flag if Task 4 needed any server touch — it should not.)

Self-Review notes (author)

  • Spec coverage: two-tab page → T1; Members consolidation (roster+roles/owner+consent+link) → T1/T2; add + pending → T3; Households tab + already_member → T4; Household tab read-only → T5; tests → T6.
  • Deliberate deviations (flag for review): (1) No bloc merge — co-locate both blocs (spec said "single bloc"); merging is high-risk (MembersBloc also owns family goals + tailored); duplication now co-located; merge deferred. (2) Route kept GovernanceRoute (no rename) to avoid codegen regen risk; page content/title become "People & Access". (3) already_member switch-offer folds in ONLY if ≤ a field already on the exception; else deferred (no SDK widening).
  • Type consistency: PeopleAccessTab {members, households} in DsSegmented + _body(); PeopleHouseholdsCubit stable; reused sheet signatures are the verified anchors.
  • Main risk = test fallout — T6 owns it explicitly.