Skip to content

Add admin panel and signup access policy#9

Merged
jwhumphries merged 17 commits into
devfrom
admin-access-policy
Jul 5, 2026
Merged

Add admin panel and signup access policy#9
jwhumphries merged 17 commits into
devfrom
admin-access-policy

Conversation

@jwhumphries

@jwhumphries jwhumphries commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • Site-admin identity via a BANDWIDTH_ADMIN_EMAILS Fly secret (never in git), checked against the logged-in user's email
  • DB-backed signup access policy (deny-all-except-allow-list, admin-toggleable) — gates registration only, never login
  • New /admin panel (reuses existing session/login) to list/delete users and bands and manage the allow-list
  • New Repo.DeleteUser cascade (personal songs/folders, bands the user created, sessions, etc.), built by factoring DeleteBand/DeleteSong's transaction bodies into reusable helpers
  • Bonus fix: deleteBandTx now also cleans up band-owned folders (a pre-existing orphan-data bug that this branch's DeleteUser cascade newly made reachable)

Full design: docs/superpowers/specs/2026-07-03-admin-access-policy-design.md
Full plan: docs/superpowers/plans/2026-07-03-admin-access-policy.md

Test plan

  • just check passes clean end-to-end (lint-go, lint-js, typecheck, format-check, test-go, test-frontend)
  • Each of the 11 plan tasks implemented and reviewed independently (spec compliance + code quality)
  • Final whole-branch review: no Critical/Important issues; access-control surface checked in both directions (no non-admin path to /api/admin/*, no signup path around the allow-list)
  • Manual: after deploy, run flyctl secrets set BANDWIDTH_ADMIN_EMAILS=you@example.com -a bandwidth to bootstrap the first admin

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin area for managing users, bands, and access policy settings.
    • Added admin-only navigation and route protection in the app.
    • Signup can now be restricted by an access policy with an allow-list, while admin emails can bypass restrictions.
  • Bug Fixes

    • Improved account deletion so related data is cleaned up more completely.
    • Band and song deletions now remove more dependent data, including folders and related entries.
  • Tests

    • Added coverage for admin access, signup restrictions, and the new admin management flows.

jwhumphries and others added 15 commits July 3, 2026 18:47
11 TDD tasks: admin-email config, AccessPolicy/AllowedEmail models,
repo CRUD, DeleteUser cascade (factored from DeleteBand/DeleteSong),
signup enforcement, RequireAdmin middleware, admin endpoints, and the
frontend admin panel (users/bands/access-policy tabs).
- Add AdminEmails map[string]bool field to API struct
- Implement IsAdminEmail method for case-insensitive email lookup
- Add BANDWIDTH_ADMIN_EMAILS env var with empty default to viper config
- Add parseAdminEmails helper to parse comma-separated admin email list
- Wire admin_emails into API initialization from viper config
- Add comprehensive tests for IsAdminEmail with edge cases

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Register the new models in AutoMigrate and add schema migration test coverage.
Task 3's repository functions will operate on these models.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implement repository layer for access policy management (enable/disable signup gating)
and allowed email list (CRUD operations). Add admin listing queries for all users and
all bands with necessary metadata for admin panel display.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extracts deleteBandTx and deleteSongRowsTx (pure refactor, verified against
the existing test suite) so the new Repo.DeleteUser can cascade a created
band and personal songs without nesting transactions. DeleteUser also
clears the deleted user's sessions, backup codes, password resets,
personal folders, memberships, personal-layer rows on other bands' songs,
and pending invites addressed to them.
Implements the access policy check in the Signup handler. Blocks
registration unless the policy is disabled, the caller is an admin email,
or the email is on the allow-list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Convert userResponse from a package-level function to a method on *API
so it can call a.IsAdminEmail() and add isAdmin field to every endpoint
that returns a user in its JSON payload. Updates 6 call sites across
account.go, auth.go, and twofa.go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the RequireAdmin middleware that rejects callers whose email
is not configured as a site admin. Must run after RequireAuth in the
middleware chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires the Task 3-4 repository methods and Task 7 RequireAdmin middleware
into 8 handler methods under /api/admin/*: list/delete users, list/delete
bands, get/set the signup access policy, and add/remove allow-list
emails. Self-delete is blocked (400) and missing users/bands/entries
404 before delete/remove.
Replaces the shared adminTargetID helper with adminUserID, adminBandID,
and adminAllowedEmailID so malformed :id path params return the same
resource-specific "not found" message as the well-formed-but-missing
case, matching the songID/memberID convention elsewhere.
Implements 8 hooks for admin panel: useAdminUsers, useDeleteAdminUser,
useAdminBands, useDeleteAdminBand, useAccessPolicy, useSetAccessPolicy,
useAddAllowedEmail, useRemoveAllowedEmail. Follows existing hook pattern
with proper query-key invalidation on mutations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resolves the App.tsx import that has referenced a nonexistent
./pages/AdminPage module since Task 9. Full just check suite (lint,
typecheck, format, Go tests, frontend tests) now passes clean end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deleteBandTx deleted a band's songs, members, and invites but left its
Folder/FolderEntry rows behind, permanently orphaned since folder
listing is always owner-scoped. DeleteUser now reaches this path when
cascading through bands the deleted user created, so fix it here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b9ce2fbd-9b2c-4bb2-8ffa-8b6ba065a58b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds an admin access-control feature: admin email configuration, an access-policy allow-list for gating signups, admin-only REST endpoints and middleware for managing users/bands/access policy, and a corresponding admin frontend UI. It also refactors band/song/user deletion into reusable transactional helpers with expanded cascading cleanup.

Changes

Admin Access Policy Feature

Layer / File(s) Summary
Access policy model and repository
internal/model/accesspolicy.go, internal/repository/admin.go, internal/repository/admin_test.go, internal/repository/repository.go, internal/repository/repository_test.go
Adds AccessPolicy/AllowedEmail models, repository methods for policy toggling, allow-list CRUD, admin user/band listings, and schema migration/tests.
Cascading delete refactor
internal/repository/bands.go, internal/repository/songs.go, internal/repository/users.go, internal/repository/bands_test.go, internal/repository/users_test.go
Extracts deleteBandTx/deleteSongRowsTx helpers, expands band deletion to clean folders, and adds Repo.DeleteUser cascading cleanup with tests.
Admin email detection and RequireAdmin middleware
internal/handlers/api.go, internal/handlers/api_test.go, internal/middleware/auth.go, internal/middleware/auth_test.go, internal/handlers/account.go, internal/handlers/twofa.go
Adds AdminEmails/IsAdminEmail, converts userResponse to a method returning isAdmin, updates existing handlers to use it, and adds RequireAdmin echo middleware.
Signup access gating
internal/handlers/auth.go, internal/handlers/auth_test.go
Signup checks access policy/allow-list for non-admin emails and rejects with 403 when not permitted; responses use a.userResponse.
Admin HTTP handlers and route wiring
internal/handlers/admin.go, internal/handlers/admin_test.go, cmd/bandwidth/server.go, cmd/bandwidth/main.go
Adds admin endpoints for users/bands/access-policy/allow-list, registers /api/admin routes with auth/admin middleware, and adds admin_emails config parsing.
Frontend admin infra
frontend/src/lib/types.ts, frontend/src/hooks/admin.ts, frontend/src/components/RequireAdmin.tsx, frontend/src/components/RequireAdmin.test.tsx, frontend/src/App.tsx, frontend/src/components/Layout.tsx
Adds admin types, React Query hooks, a RequireAdmin route guard, and wires the /admin route plus nav link.
AdminPage UI
frontend/src/pages/AdminPage.tsx, frontend/src/pages/AdminPage.test.tsx
Implements tabbed Users/Bands/Access Policy admin panels with delete confirmation and email allow-list management.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Signup as Signup handler
  participant Repo as Repository
  participant User as Requesting user

  User->>Signup: POST /api/signup {email, ...}
  Signup->>Signup: check IsAdminEmail(email)
  alt not admin
    Signup->>Repo: AccessPolicyEnabled()
    Repo-->>Signup: enabled/disabled
    alt policy enabled
      Signup->>Repo: EmailAllowed(email)
      Repo-->>Signup: allowed/not allowed
      alt not allowed
        Signup-->>User: 403 registration is not open
      end
    end
  end
  Signup->>Repo: create user
  Signup-->>User: 201 Created (a.userResponse)
Loading
sequenceDiagram
  participant AdminPage
  participant RequireAdmin
  participant AdminHandlers as Admin handlers
  participant Repo as Repository

  AdminPage->>RequireAdmin: navigate to /admin
  RequireAdmin->>AdminHandlers: GET /api/me
  AdminHandlers-->>RequireAdmin: isAdmin true/false
  alt isAdmin false
    RequireAdmin-->>AdminPage: redirect to /
  else isAdmin true
    RequireAdmin-->>AdminPage: render Outlet
    AdminPage->>AdminHandlers: GET /api/admin/users, /bands, /access-policy
    AdminHandlers->>Repo: AllUsers/AllBands/AccessPolicyEnabled/AllowedEmails
    Repo-->>AdminHandlers: rows
    AdminHandlers-->>AdminPage: JSON data
  end
Loading

Possibly related PRs

  • jwhumphries/BandWidth#1: Both PRs modify the userResponse construction and Signup/Login handler response paths in internal/handlers/api.go and internal/handlers/auth.go.

Poem

A rabbit hops through gate and door,
"Admin only!" says the lore. 🐇
Emails allowed, policies set,
Users deleted with no regret.
Hop, hop, cascade — clean as can be,
A tidy warren for you and me! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: an admin panel plus signup access-policy support.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch admin-access-policy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jwhumphries

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/hooks/admin.ts`:
- Around line 12-19: The useDeleteAdminUser mutation only refreshes the admin
users cache after deletion, but it also needs to refresh the bands data because
DeleteUser cascades to bands owned by that user. Update the onSuccess handler in
useDeleteAdminUser to invalidate the bands query cache as well as the existing
['admin', 'users'] query key, using the same queryClient.invalidateQueries
pattern so the Bands tab stays in sync.

In `@frontend/src/pages/AdminPage.tsx`:
- Around line 24-46: Add aria-selected to each role="tab" button in AdminPage so
assistive tech can detect the active tab, not just the tab-active class. Update
the tab buttons in the tabs markup to set aria-selected based on the current tab
state in the same place where setTab and tab-active are used, keeping the
selected tab true and the others false.

In `@internal/handlers/admin_test.go`:
- Around line 39-186: The admin tests are written as linear steps where some
independent cases could be table-driven. Refactor the clearly separable
sub-cases in TestAdminDeleteUser, and any similar independent assertions in
TestAdminUsersRequiresAdmin, TestAdminBandsAndDelete, and TestAdminAccessPolicy,
into table-driven loops while keeping the stateful setup steps outside the
tables. Use the existing test functions and helpers like jsonReq,
signupAndCookie, and mustAdminUserID to group only the cases that do not depend
on mutated shared state.
- Around line 39-186: `jsonReq` is missing the required fetch-metadata header
for mutating admin requests, so these tests bypass the same CSRF path used in
production. Update the shared `jsonReq` helper to always send `Sec-Fetch-Site:
same-origin` for the admin POST/PUT/DELETE calls exercised in
`TestAdminDeleteUser`, `TestAdminBandsAndDelete`, and `TestAdminAccessPolicy`,
so the handlers are tested through the intended protection path.

In `@internal/handlers/admin.go`:
- Around line 50-171: Add structured audit logging to the privileged admin
handlers in API: AdminDeleteUser, AdminDeleteBand, AdminSetAccessPolicy,
AdminAddAllowedEmail, and AdminRemoveAllowedEmail. Use a.Logger to record the
authenticated actor from appmw.CurrentUser(c), the target identifier or changed
value, and the action taken before or after the Repo mutation succeeds. Keep the
log entries consistent and descriptive so these destructive/admin operations can
be traced later.

In `@internal/model/accesspolicy.go`:
- Around line 12-18: Delete or reassign any AllowedEmail rows owned by the user
being removed so CreatedBy does not point to a deleted account. Update
DeleteUser in users.go to handle AllowedEmail cleanup within the same
transaction, using the AllowedEmail.CreatedBy field and the user deletion flow
together so ownership is scrubbed atomically.

In `@internal/repository/bands.go`:
- Around line 182-193: The folder cleanup in the band repository uses an N+1
delete pattern and duplicates the same logic in the user repository. Refactor
the repeated find-and-delete flow around the band folder cleanup code into a
shared helper that deletes all matching FolderEntry rows in one batched query
using the folder IDs, then deletes the folders, and reuse that helper from both
the band and user repository paths. Locate the duplicated logic in the band
cleanup routine and the corresponding owner_user_id cleanup in users.go so both
call the same reusable function.

In `@internal/repository/users.go`:
- Around line 84-95: The folder cleanup in the users repository repeats the same
N+1 delete pattern as the band cleanup, so replace the inline loop in the users
delete flow with the shared helper used by the corresponding logic in bands.
Update the code around the user deletion path to call the reusable
deleteOwnedFolders(tx, ownerColumn, ownerID) helper instead of querying folders
and deleting FolderEntry records one folder at a time, and keep the final Folder
delete using the same owner column filtering.
- Around line 96-101: DeleteUser currently removes BandMember and BandInvite
rows tied to the user as invitee, but it also needs to delete invites authored
by that user via BandInvite.created_by. Update the deletion logic in DeleteUser
to include a cleanup for band_invites where created_by equals userID, alongside
the existing tx.Where(...).Delete calls, so stale authored invites are removed
too.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7a240260-2e2e-44d4-ad2f-904da0b27a7f

📥 Commits

Reviewing files that changed from the base of the PR and between 1d9ce08 and 043a341.

📒 Files selected for processing (32)
  • .gitignore
  • cmd/bandwidth/main.go
  • cmd/bandwidth/server.go
  • docs/superpowers/plans/2026-07-03-admin-access-policy.md
  • frontend/src/App.tsx
  • frontend/src/components/Layout.tsx
  • frontend/src/components/RequireAdmin.test.tsx
  • frontend/src/components/RequireAdmin.tsx
  • frontend/src/hooks/admin.ts
  • frontend/src/lib/types.ts
  • frontend/src/pages/AdminPage.test.tsx
  • frontend/src/pages/AdminPage.tsx
  • internal/handlers/account.go
  • internal/handlers/admin.go
  • internal/handlers/admin_test.go
  • internal/handlers/api.go
  • internal/handlers/api_test.go
  • internal/handlers/auth.go
  • internal/handlers/auth_test.go
  • internal/handlers/twofa.go
  • internal/middleware/auth.go
  • internal/middleware/auth_test.go
  • internal/model/accesspolicy.go
  • internal/repository/admin.go
  • internal/repository/admin_test.go
  • internal/repository/bands.go
  • internal/repository/bands_test.go
  • internal/repository/repository.go
  • internal/repository/repository_test.go
  • internal/repository/songs.go
  • internal/repository/users.go
  • internal/repository/users_test.go

Comment on lines +12 to +19
export function useDeleteAdminUser() {
const queryClient = useQueryClient();
return useMutation<void, ApiError, number>({
mutationFn: id => api.delete(`/api/admin/users/${id}`),
onSuccess: () =>
void queryClient.invalidateQueries({queryKey: ['admin', 'users']}),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Invalidate bands cache too — user deletion cascades to owned bands.

Per the PR's cascading DeleteUser behavior, deleting a user also deletes bands they created. This mutation only invalidates ['admin', 'users'], so the Bands tab can keep showing an already-deleted band until an unrelated refetch happens.

🔧 Proposed fix
 export function useDeleteAdminUser() {
   const queryClient = useQueryClient();
   return useMutation<void, ApiError, number>({
     mutationFn: id => api.delete(`/api/admin/users/${id}`),
-    onSuccess: () =>
-      void queryClient.invalidateQueries({queryKey: ['admin', 'users']}),
+    onSuccess: () =>
+      void Promise.all([
+        queryClient.invalidateQueries({queryKey: ['admin', 'users']}),
+        queryClient.invalidateQueries({queryKey: ['admin', 'bands']}),
+      ]),
   });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function useDeleteAdminUser() {
const queryClient = useQueryClient();
return useMutation<void, ApiError, number>({
mutationFn: id => api.delete(`/api/admin/users/${id}`),
onSuccess: () =>
void queryClient.invalidateQueries({queryKey: ['admin', 'users']}),
});
}
export function useDeleteAdminUser() {
const queryClient = useQueryClient();
return useMutation<void, ApiError, number>({
mutationFn: id => api.delete(`/api/admin/users/${id}`),
onSuccess: () =>
void Promise.all([
queryClient.invalidateQueries({queryKey: ['admin', 'users']}),
queryClient.invalidateQueries({queryKey: ['admin', 'bands']}),
]),
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/admin.ts` around lines 12 - 19, The useDeleteAdminUser
mutation only refreshes the admin users cache after deletion, but it also needs
to refresh the bands data because DeleteUser cascades to bands owned by that
user. Update the onSuccess handler in useDeleteAdminUser to invalidate the bands
query cache as well as the existing ['admin', 'users'] query key, using the same
queryClient.invalidateQueries pattern so the Bands tab stays in sync.

Comment on lines +24 to +46
<div role="tablist" className="tabs tabs-boxed w-fit">
<button
role="tab"
className={`tab ${tab === 'users' ? 'tab-active' : ''}`}
onClick={() => setTab('users')}
>
Users
</button>
<button
role="tab"
className={`tab ${tab === 'bands' ? 'tab-active' : ''}`}
onClick={() => setTab('bands')}
>
Bands
</button>
<button
role="tab"
className={`tab ${tab === 'access' ? 'tab-active' : ''}`}
onClick={() => setTab('access')}
>
Access policy
</button>
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tabs missing aria-selected for the active tab.

The role="tab" buttons only convey active state via the tab-active CSS class; screen readers won't know which tab is selected without aria-selected.

🔧 Proposed fix
         <button
           role="tab"
+          aria-selected={tab === 'users'}
           className={`tab ${tab === 'users' ? 'tab-active' : ''}`}
           onClick={() => setTab('users')}
         >
           Users
         </button>
         <button
           role="tab"
+          aria-selected={tab === 'bands'}
           className={`tab ${tab === 'bands' ? 'tab-active' : ''}`}
           onClick={() => setTab('bands')}
         >
           Bands
         </button>
         <button
           role="tab"
+          aria-selected={tab === 'access'}
           className={`tab ${tab === 'access' ? 'tab-active' : ''}`}
           onClick={() => setTab('access')}
         >
           Access policy
         </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div role="tablist" className="tabs tabs-boxed w-fit">
<button
role="tab"
className={`tab ${tab === 'users' ? 'tab-active' : ''}`}
onClick={() => setTab('users')}
>
Users
</button>
<button
role="tab"
className={`tab ${tab === 'bands' ? 'tab-active' : ''}`}
onClick={() => setTab('bands')}
>
Bands
</button>
<button
role="tab"
className={`tab ${tab === 'access' ? 'tab-active' : ''}`}
onClick={() => setTab('access')}
>
Access policy
</button>
</div>
<div role="tablist" className="tabs tabs-boxed w-fit">
<button
role="tab"
aria-selected={tab === 'users'}
className={`tab ${tab === 'users' ? 'tab-active' : ''}`}
onClick={() => setTab('users')}
>
Users
</button>
<button
role="tab"
aria-selected={tab === 'bands'}
className={`tab ${tab === 'bands' ? 'tab-active' : ''}`}
onClick={() => setTab('bands')}
>
Bands
</button>
<button
role="tab"
aria-selected={tab === 'access'}
className={`tab ${tab === 'access' ? 'tab-active' : ''}`}
onClick={() => setTab('access')}
>
Access policy
</button>
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/pages/AdminPage.tsx` around lines 24 - 46, Add aria-selected to
each role="tab" button in AdminPage so assistive tech can detect the active tab,
not just the tab-active class. Update the tab buttons in the tabs markup to set
aria-selected based on the current tab state in the same place where setTab and
tab-active are used, keeping the selected tab true and the others false.

Comment on lines +39 to +186
func TestAdminUsersRequiresAdmin(t *testing.T) {
e, api := newAdminAPI(t)
bob := signupAndCookie(t, e, "bob")
rec := jsonReq(e, http.MethodGet, "/api/admin/users", "", bob)
if rec.Code != http.StatusForbidden {
t.Fatalf("non-admin: %d, want 403", rec.Code)
}

api.AdminEmails = map[string]bool{"admin@example.com": true}
admin := signupAndCookie(t, e, "admin")
rec = jsonReq(e, http.MethodGet, "/api/admin/users", "", admin)
if rec.Code != http.StatusOK {
t.Fatalf("admin: %d %s", rec.Code, rec.Body.String())
}
var users []struct {
Username string `json:"username"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &users); err != nil {
t.Fatal(err)
}
if len(users) != 2 {
t.Fatalf("users = %+v, want 2", users)
}
}

func TestAdminDeleteUser(t *testing.T) {
e, api := newAdminAPI(t)
api.AdminEmails = map[string]bool{"admin@example.com": true}
admin := signupAndCookie(t, e, "admin")
signupAndCookie(t, e, "bob")
bobID := mustAdminUserID(t, api, "bob")
adminID := mustAdminUserID(t, api, "admin")

rec := jsonReq(e, http.MethodDelete, fmt.Sprintf("/api/admin/users/%d", adminID), "", admin)
if rec.Code != http.StatusBadRequest {
t.Fatalf("self-delete: %d, want 400", rec.Code)
}

rec = jsonReq(e, http.MethodDelete, "/api/admin/users/abc", "", admin)
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "user not found") {
t.Fatalf("delete malformed user id: %d %s, want 404 user not found", rec.Code, rec.Body.String())
}

rec = jsonReq(e, http.MethodDelete, "/api/admin/users/9999", "", admin)
if rec.Code != http.StatusNotFound {
t.Fatalf("delete missing user: %d, want 404", rec.Code)
}

rec = jsonReq(e, http.MethodDelete, fmt.Sprintf("/api/admin/users/%d", bobID), "", admin)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete bob: %d %s", rec.Code, rec.Body.String())
}
if _, err := api.Repo.UserByID(bobID); err == nil {
t.Error("bob survived admin delete")
}
}

func TestAdminBandsAndDelete(t *testing.T) {
e, api := newAdminAPI(t)
api.AdminEmails = map[string]bool{"admin@example.com": true}
admin := signupAndCookie(t, e, "admin")
adminID := mustAdminUserID(t, api, "admin")
band, err := api.Repo.CreateBand(adminID, "The Quietones")
if err != nil {
t.Fatal(err)
}

rec := jsonReq(e, http.MethodGet, "/api/admin/bands", "", admin)
if rec.Code != http.StatusOK {
t.Fatalf("list bands: %d %s", rec.Code, rec.Body.String())
}
var bands []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &bands); err != nil {
t.Fatal(err)
}
if len(bands) != 1 || bands[0].Name != "The Quietones" {
t.Fatalf("bands = %+v", bands)
}

rec = jsonReq(e, http.MethodDelete, "/api/admin/bands/9999", "", admin)
if rec.Code != http.StatusNotFound {
t.Fatalf("delete missing band: %d, want 404", rec.Code)
}

rec = jsonReq(e, http.MethodDelete, fmt.Sprintf("/api/admin/bands/%d", band.ID), "", admin)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete band: %d %s", rec.Code, rec.Body.String())
}
if _, err := api.Repo.BandByID(band.ID); err == nil {
t.Error("band survived admin delete")
}
}

func TestAdminAccessPolicy(t *testing.T) {
e, api := newAdminAPI(t)
api.AdminEmails = map[string]bool{"admin@example.com": true}
admin := signupAndCookie(t, e, "admin")

rec := jsonReq(e, http.MethodGet, "/api/admin/access-policy", "", admin)
var policy struct {
Enabled bool `json:"enabled"`
AllowedEmails []struct {
ID uint `json:"id"`
Email string `json:"email"`
} `json:"allowedEmails"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &policy); err != nil {
t.Fatal(err)
}
if policy.Enabled || len(policy.AllowedEmails) != 0 {
t.Fatalf("initial policy = %+v", policy)
}

rec = jsonReq(e, http.MethodPut, "/api/admin/access-policy", `{"enabled":true}`, admin)
if rec.Code != http.StatusNoContent {
t.Fatalf("enable: %d %s", rec.Code, rec.Body.String())
}

rec = jsonReq(e, http.MethodPost, "/api/admin/access-policy/emails",
`{"email":"Friend@Example.com"}`, admin)
if rec.Code != http.StatusCreated {
t.Fatalf("add email: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ID uint `json:"id"`
Email string `json:"email"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if created.Email != "friend@example.com" {
t.Errorf("email not normalized: %q", created.Email)
}

rec = jsonReq(e, http.MethodPost, "/api/admin/access-policy/emails",
`{"email":"friend@example.com"}`, admin)
if rec.Code != http.StatusConflict {
t.Fatalf("duplicate email: %d, want 409", rec.Code)
}

rec = jsonReq(e, http.MethodDelete,
fmt.Sprintf("/api/admin/access-policy/emails/%d", created.ID), "", admin)
if rec.Code != http.StatusNoContent {
t.Fatalf("remove email: %d %s", rec.Code, rec.Body.String())
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tests are sequential rather than table-driven.

TestAdminUsersRequiresAdmin, TestAdminDeleteUser, TestAdminBandsAndDelete, and TestAdminAccessPolicy run linear multi-step assertions instead of table-driven cases. As per coding guidelines, **/*_test.go files should "Use table-driven tests alongside source code in *_test.go files for Go tests." Some steps here build on mutated shared state (e.g., toggling policy then adding/removing emails), so a literal table isn't a perfect fit everywhere, but sub-cases like the malformed/missing/self-delete checks in TestAdminDeleteUser are good table-driven candidates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/handlers/admin_test.go` around lines 39 - 186, The admin tests are
written as linear steps where some independent cases could be table-driven.
Refactor the clearly separable sub-cases in TestAdminDeleteUser, and any similar
independent assertions in TestAdminUsersRequiresAdmin, TestAdminBandsAndDelete,
and TestAdminAccessPolicy, into table-driven loops while keeping the stateful
setup steps outside the tables. Use the existing test functions and helpers like
jsonReq, signupAndCookie, and mustAdminUserID to group only the cases that do
not depend on mutated shared state.

Source: Coding guidelines


🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -B2 -A 15 'func jsonReq' internal/handlers

Repository: jwhumphries/BandWidth

Length of output: 1302


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== jsonReq helper =="
sed -n '1,70p' internal/handlers/account_test.go

echo
echo "== Sec-Fetch-Site usage in handler tests =="
rg -n 'Sec-Fetch-Site|jsonReq\(' internal/handlers/*_test.go

Repository: jwhumphries/BandWidth

Length of output: 20192


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== newTestAPI / test router setup =="
rg -n -A 40 -B 10 'func newTestAPI|RequireAuth|RequireAdmin|Sec-Fetch-Site|Fetch' internal/handlers internal/middleware

echo
echo "== admin test file around helper calls =="
sed -n '1,220p' internal/handlers/admin_test.go

Repository: jwhumphries/BandWidth

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== middleware implementation =="
sed -n '1,260p' internal/middleware/*.go

echo
echo "== fetch-metadata / CSRF references =="
rg -n 'Sec-Fetch-Site|fetch metadata|CSRF|csrf' internal/middleware internal/handlers

Repository: jwhumphries/BandWidth

Length of output: 4948


Add Sec-Fetch-Site: same-origin to jsonReq
jsonReq is used for the POST/PUT/DELETE admin calls here, but it never sets the required fetch-metadata header. Set it in the shared helper so all mutating handler tests exercise the same CSRF path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/handlers/admin_test.go` around lines 39 - 186, `jsonReq` is missing
the required fetch-metadata header for mutating admin requests, so these tests
bypass the same CSRF path used in production. Update the shared `jsonReq` helper
to always send `Sec-Fetch-Site: same-origin` for the admin POST/PUT/DELETE calls
exercised in `TestAdminDeleteUser`, `TestAdminBandsAndDelete`, and
`TestAdminAccessPolicy`, so the handlers are tested through the intended
protection path.

Source: Coding guidelines

Comment on lines +50 to +171
// AdminDeleteUser deletes a user and everything they solely own. Admins
// cannot delete their own account (avoids self-lockout).
func (a *API) AdminDeleteUser(c *echo.Context) error {
admin := appmw.CurrentUser(c)
if admin == nil {
return echo.NewHTTPError(http.StatusInternalServerError, "user not in context")
}
id, err := adminUserID(c)
if err != nil {
return err
}
if id == admin.ID {
return echo.NewHTTPError(http.StatusBadRequest, "cannot delete your own account")
}
if _, err := a.Repo.UserByID(id); err != nil {
return notFoundOr(err, "user")
}
if err := a.Repo.DeleteUser(id); err != nil {
return err
}
return c.NoContent(http.StatusNoContent)
}

// AdminBands lists every band.
func (a *API) AdminBands(c *echo.Context) error {
bands, err := a.Repo.AllBands()
if err != nil {
return err
}
return c.JSON(http.StatusOK, bands)
}

// AdminDeleteBand deletes any band.
func (a *API) AdminDeleteBand(c *echo.Context) error {
id, err := adminBandID(c)
if err != nil {
return err
}
if _, err := a.Repo.BandByID(id); err != nil {
return notFoundOr(err, "band")
}
if err := a.Repo.DeleteBand(id); err != nil {
return err
}
return c.NoContent(http.StatusNoContent)
}

// AdminGetAccessPolicy returns the signup gate state and its allow-list.
func (a *API) AdminGetAccessPolicy(c *echo.Context) error {
enabled, err := a.Repo.AccessPolicyEnabled()
if err != nil {
return err
}
emails, err := a.Repo.AllowedEmails()
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]any{
"enabled": enabled,
"allowedEmails": emails,
})
}

type setAccessPolicyRequest struct {
Enabled bool `json:"enabled"`
}

// AdminSetAccessPolicy toggles signup gating.
func (a *API) AdminSetAccessPolicy(c *echo.Context) error {
var req setAccessPolicyRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if err := a.Repo.SetAccessPolicyEnabled(req.Enabled); err != nil {
return err
}
return c.NoContent(http.StatusNoContent)
}

type addAllowedEmailRequest struct {
Email string `json:"email"`
}

// AdminAddAllowedEmail adds an email to the signup allow-list.
func (a *API) AdminAddAllowedEmail(c *echo.Context) error {
admin := appmw.CurrentUser(c)
if admin == nil {
return echo.NewHTTPError(http.StatusInternalServerError, "user not in context")
}
var req addAllowedEmailRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if !validEmail(email) {
return echo.NewHTTPError(http.StatusBadRequest, "a valid email address is required")
}
entry, err := a.Repo.AddAllowedEmail(email, admin.ID)
if err != nil {
if repository.IsDuplicate(err) {
return echo.NewHTTPError(http.StatusConflict, "email already on the allow-list")
}
return err
}
return c.JSON(http.StatusCreated, map[string]any{
"id": entry.ID,
"email": entry.Email,
"createdAt": entry.CreatedAt,
})
}

// AdminRemoveAllowedEmail removes an allow-list entry.
func (a *API) AdminRemoveAllowedEmail(c *echo.Context) error {
id, err := adminAllowedEmailID(c)
if err != nil {
return err
}
if err := a.Repo.RemoveAllowedEmail(id); err != nil {
return notFoundOr(err, "allow-list entry")
}
return c.NoContent(http.StatusNoContent)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial

No audit trail for destructive/privileged admin actions.

Deleting users, deleting bands, toggling the signup gate, and mutating the allow-list have no logging (a.Logger is on the API struct but unused here). For a privileged admin surface, structured audit logs (actor, target, action, timestamp) would materially help with incident response and abuse detection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/handlers/admin.go` around lines 50 - 171, Add structured audit
logging to the privileged admin handlers in API: AdminDeleteUser,
AdminDeleteBand, AdminSetAccessPolicy, AdminAddAllowedEmail, and
AdminRemoveAllowedEmail. Use a.Logger to record the authenticated actor from
appmw.CurrentUser(c), the target identifier or changed value, and the action
taken before or after the Repo mutation succeeds. Keep the log entries
consistent and descriptive so these destructive/admin operations can be traced
later.

Comment on lines +12 to +18
// AllowedEmail is one entry on the signup allow-list.
type AllowedEmail struct {
ID uint `gorm:"primarykey"`
Email string `gorm:"uniqueIndex;not null"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether DeleteUser cleans up AllowedEmail rows created by the deleted user
rg -n -A5 -B5 'func \(r \*Repo\) DeleteUser' internal/repository/users.go
rg -n 'AllowedEmail' internal/repository/users.go

Repository: jwhumphries/BandWidth

Length of output: 790


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate AllowedEmail definitions/usages and inspect the full DeleteUser implementation.
git ls-files | rg '^internal/(model|repository)/.*\.go$'
rg -n -A40 -B10 'func \(r \*Repo\) DeleteUser|AllowedEmail|CreatedBy' internal/repository internal/model

Repository: jwhumphries/BandWidth

Length of output: 25748


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the remainder of DeleteUser and any user-deletion cleanup related to AllowedEmail.
sed -n '59,170p' internal/repository/users.go
printf '\n---\n'
rg -n -A3 -B3 'AllowedEmail|created_by|DeleteUser' internal/repository/users.go internal/repository/admin.go internal/model/accesspolicy.go

Repository: jwhumphries/BandWidth

Length of output: 6270


Scrub allow-list ownership on user deletion
internal/repository/users.go: DeleteUser leaves AllowedEmail.CreatedBy pointing at the removed user. Delete or reassign those rows in the same transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/model/accesspolicy.go` around lines 12 - 18, Delete or reassign any
AllowedEmail rows owned by the user being removed so CreatedBy does not point to
a deleted account. Update DeleteUser in users.go to handle AllowedEmail cleanup
within the same transaction, using the AllowedEmail.CreatedBy field and the user
deletion flow together so ownership is scrubbed atomically.

Comment thread internal/repository/bands.go Outdated
Comment on lines +182 to +193
var folders []model.Folder
if err := tx.Where("owner_band_id = ?", bandID).Find(&folders).Error; err != nil {
return err
}
for _, f := range folders {
if err := tx.Where("folder_id = ?", f.ID).Delete(&model.FolderEntry{}).Error; err != nil {
return err
}
return tx.Delete(&model.Band{}, bandID).Error
})
}
if err := tx.Where("owner_band_id = ?", bandID).Delete(&model.Folder{}).Error; err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 delete pattern for band folder entries; also duplicated in users.go.

Each folder triggers its own DELETE ... WHERE folder_id = ? query. This same three-statement pattern (find folders → per-folder entry delete → delete folders) is repeated almost verbatim in internal/repository/users.go (Lines 84-95) for owner_user_id. Extracting a shared, batched helper removes the duplication and collapses N+1 deletes into a single query.

♻️ Proposed shared helper (batched, reusable)
+// deleteOwnedFolders removes folders (and their entries) matching
+// ownerColumn = ownerID, e.g. "owner_band_id" or "owner_user_id".
+func deleteOwnedFolders(tx *gorm.DB, ownerColumn string, ownerID uint) error {
+	var folders []model.Folder
+	if err := tx.Where(ownerColumn+" = ?", ownerID).Find(&folders).Error; err != nil {
+		return err
+	}
+	ids := make([]uint, len(folders))
+	for i, f := range folders {
+		ids[i] = f.ID
+	}
+	if len(ids) > 0 {
+		if err := tx.Where("folder_id IN ?", ids).Delete(&model.FolderEntry{}).Error; err != nil {
+			return err
+		}
+	}
+	return tx.Where(ownerColumn+" = ?", ownerID).Delete(&model.Folder{}).Error
+}
-	var folders []model.Folder
-	if err := tx.Where("owner_band_id = ?", bandID).Find(&folders).Error; err != nil {
-		return err
-	}
-	for _, f := range folders {
-		if err := tx.Where("folder_id = ?", f.ID).Delete(&model.FolderEntry{}).Error; err != nil {
-			return err
-		}
-	}
-	if err := tx.Where("owner_band_id = ?", bandID).Delete(&model.Folder{}).Error; err != nil {
-		return err
-	}
+	if err := deleteOwnedFolders(tx, "owner_band_id", bandID); err != nil {
+		return err
+	}
 	return tx.Delete(&model.Band{}, bandID).Error
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var folders []model.Folder
if err := tx.Where("owner_band_id = ?", bandID).Find(&folders).Error; err != nil {
return err
}
for _, f := range folders {
if err := tx.Where("folder_id = ?", f.ID).Delete(&model.FolderEntry{}).Error; err != nil {
return err
}
return tx.Delete(&model.Band{}, bandID).Error
})
}
if err := tx.Where("owner_band_id = ?", bandID).Delete(&model.Folder{}).Error; err != nil {
return err
}
// deleteOwnedFolders removes folders (and their entries) matching
// ownerColumn = ownerID, e.g. "owner_band_id" or "owner_user_id".
func deleteOwnedFolders(tx *gorm.DB, ownerColumn string, ownerID uint) error {
var folders []model.Folder
if err := tx.Where(ownerColumn+" = ?", ownerID).Find(&folders).Error; err != nil {
return err
}
ids := make([]uint, len(folders))
for i, f := range folders {
ids[i] = f.ID
}
if len(ids) > 0 {
if err := tx.Where("folder_id IN ?", ids).Delete(&model.FolderEntry{}).Error; err != nil {
return err
}
}
return tx.Where(ownerColumn+" = ?", ownerID).Delete(&model.Folder{}).Error
}
if err := deleteOwnedFolders(tx, "owner_band_id", bandID); err != nil {
return err
}
return tx.Delete(&model.Band{}, bandID).Error
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/repository/bands.go` around lines 182 - 193, The folder cleanup in
the band repository uses an N+1 delete pattern and duplicates the same logic in
the user repository. Refactor the repeated find-and-delete flow around the band
folder cleanup code into a shared helper that deletes all matching FolderEntry
rows in one batched query using the folder IDs, then deletes the folders, and
reuse that helper from both the band and user repository paths. Locate the
duplicated logic in the band cleanup routine and the corresponding owner_user_id
cleanup in users.go so both call the same reusable function.

Comment thread internal/repository/users.go Outdated
Comment on lines +84 to +95
var folders []model.Folder
if err := tx.Where("owner_user_id = ?", userID).Find(&folders).Error; err != nil {
return err
}
for _, f := range folders {
if err := tx.Where("folder_id = ?", f.ID).Delete(&model.FolderEntry{}).Error; err != nil {
return err
}
}
if err := tx.Where("owner_user_id = ?", userID).Delete(&model.Folder{}).Error; err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Same N+1/duplicated folder-entry deletion pattern as bands.go.

This block is identical in shape to deleteBandTx's folder cleanup in internal/repository/bands.go (Lines 182-193), just parameterized by owner_user_id instead of owner_band_id. See the proposed deleteOwnedFolders(tx, ownerColumn, ownerID) helper in that file's review comment — replacing both call sites removes the duplication and the per-folder query.

♻️ Proposed change using shared helper
-		var folders []model.Folder
-		if err := tx.Where("owner_user_id = ?", userID).Find(&folders).Error; err != nil {
-			return err
-		}
-		for _, f := range folders {
-			if err := tx.Where("folder_id = ?", f.ID).Delete(&model.FolderEntry{}).Error; err != nil {
-				return err
-			}
-		}
-		if err := tx.Where("owner_user_id = ?", userID).Delete(&model.Folder{}).Error; err != nil {
-			return err
-		}
+		if err := deleteOwnedFolders(tx, "owner_user_id", userID); err != nil {
+			return err
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/repository/users.go` around lines 84 - 95, The folder cleanup in the
users repository repeats the same N+1 delete pattern as the band cleanup, so
replace the inline loop in the users delete flow with the shared helper used by
the corresponding logic in bands. Update the code around the user deletion path
to call the reusable deleteOwnedFolders(tx, ownerColumn, ownerID) helper instead
of querying folders and deleting FolderEntry records one folder at a time, and
keep the final Folder delete using the same owner column filtering.

Comment on lines +96 to +101
if err := tx.Where("user_id = ?", userID).Delete(&model.BandMember{}).Error; err != nil {
return err
}
if err := tx.Where("invited_user_id = ?", userID).Delete(&model.BandInvite{}).Error; err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether invite creation is restricted to the band creator only, or any admin member.
rg -n -B5 -A15 'BandInvite\{' internal/handlers internal/repository | rg -n -B10 -A10 'CreatedBy'

Repository: jwhumphries/BandWidth

Length of output: 2776


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first, then inspect the invite creation and delete paths.
git ls-files internal/repository internal/handler internal/handlers internal/model | sed -n '1,200p'

printf '\n--- internal/repository/bandinvites.go ---\n'
cat -n internal/repository/bandinvites.go | sed -n '1,220p'

printf '\n--- internal/repository/users.go ---\n'
cat -n internal/repository/users.go | sed -n '1,220p'

printf '\n--- internal/model/bands.go ---\n'
cat -n internal/model/bands.go | sed -n '1,220p'

printf '\n--- invite creation call sites ---\n'
rg -n 'CreateDirectInvite|CreateLinkInvite|CreatedBy|BandInvite' internal

Repository: jwhumphries/BandWidth

Length of output: 22994


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- internal/handlers/bandinvites.go ---\n'
cat -n internal/handlers/bandinvites.go | sed -n '1,220p'

printf '\n--- deleteBandTx in internal/repository/bands.go ---\n'
rg -n -A40 -B20 'func deleteBandTx|Delete\(&model.BandInvite' internal/repository/bands.go

printf '\n--- invite-related tests ---\n'
cat -n internal/repository/bandinvites_test.go | sed -n '1,240p'

Repository: jwhumphries/BandWidth

Length of output: 16395


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- band access helper ---\n'
rg -n -A80 -B20 'func \(a \*API\) bandAccess|bandAccess\(' internal/handlers internal/middleware internal/repository

printf '\n--- role/member lookup paths ---\n'
rg -n -A20 -B10 'RoleAdmin|MemberRole|CreatorID|Creator' internal/repository internal/handlers internal/model

Repository: jwhumphries/BandWidth

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -A3 -B3 'CreatedBy|created_by|invited by|invitedBy' internal

Repository: jwhumphries/BandWidth

Length of output: 2312


Delete the user’s authored invites too
DeleteUser removes invites addressed to the user and invites for bands they created, but it leaves band_invites.created_by = userID rows on bands they don’t own. Since non-creator admins can create direct and link invites, deleting such a user leaves stale CreatedBy references in band_invites. Consider cascading those rows as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/repository/users.go` around lines 96 - 101, DeleteUser currently
removes BandMember and BandInvite rows tied to the user as invitee, but it also
needs to delete invites authored by that user via BandInvite.created_by. Update
the deletion logic in DeleteUser to include a cleanup for band_invites where
created_by equals userID, alongside the existing tx.Where(...).Delete calls, so
stale authored invites are removed too.

jwhumphries and others added 2 commits July 5, 2026 12:27
Lets the admin panel be tested locally via `just dev` by exporting the
var on the host shell before starting the dev container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Invalidate the bands query when an admin deletes a user, since
  DeleteUser cascades to bands that user created
- Add aria-selected to the admin panel's tab buttons
- Add audit logging (actor + target) to all five destructive/mutating
  admin handlers
- Extract deleteOwnedFoldersTx, a shared batched-delete helper, to
  de-duplicate the band/user folder-cleanup logic in deleteBandTx and
  DeleteUser

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jwhumphries jwhumphries merged commit 1f5592a into dev Jul 5, 2026
3 checks passed
@jwhumphries jwhumphries deleted the admin-access-policy branch July 5, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant