Skip to content

Invitation workflow: data layer, routes, controllers, mailer, views, and request specs - #112

Open
candyhazlett wants to merge 25 commits into
mainfrom
membership-invite-workflow
Open

Invitation workflow: data layer, routes, controllers, mailer, views, and request specs#112
candyhazlett wants to merge 25 commits into
mainfrom
membership-invite-workflow

Conversation

@candyhazlett

@candyhazlett candyhazlett commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Full implementation of the project member invitation workflow (Card 1.46):

Data layer

  • project_invitations table: token (urlsafe base64, unique), creator FK, expires_at (90 days), revoked_at
  • ProjectMember status enum: pending: 0, active: 1 (default active); pending members are locked out of membership-gated access — they retain read access to public content but do not gain member-level access to private project content until confirmed
  • needs_admin_vetting on ProjectMember, persisted rather than kept in a transient session flag: only accounts registered brand-new via the invite link go through admin vetting; existing TAPAS users accepting an invite skip straight to owner confirmation

Routes, controllers, mailer

  • POST /projects/:id/project_invitations — owner generates invitation link
  • DELETE /projects/:id/project_invitations/:id — owner revokes link
  • GET /invitations/:token — public show with expired/revoked variants
  • POST /invitations/:token/accept — authenticated accept, creates pending member, notifies admin or owner depending on needs_admin_vetting
  • PATCH /projects/:id/project_members/:id/confirm — owner confirms pending → active
  • PATCH /admin/project_members/:id/approve — admin approves pending, triggers owner confirmation email
  • InvitationMailer: admin_vetting_notification + owner_confirmation_request

Views and management page

  • projects#show: owner-only sections — generate invitation link (with copy-able URL), active invitations list with revoke, pending members list with confirm/dismiss
  • invitations/show: project name, creator name, accept button; distinct expired vs. revoked messaging
  • admin/project_members#review_queue: admin-only queue of pending members awaiting vetting, with an approve action — there was previously no UI path to that action at all
  • admin/project_invitations#index: admin-side visibility into active invitation links, parity with what a project owner can already do; scoped to a narrow read + revoke view rather than full CRUD, since the invitation token is a live unhashed credential

Request spec suite

  • All invitation workflow paths covered (generate, show, accept, revoke, confirm, approve)
  • Regression: direct-add via ProjectMembersController#create still produces active status

Test plan

  • 814 examples, 0 failures (full suite — CI green)
  • Generate invitation link as project owner → link appears in active invitations list
  • Follow invitation link → shows project name, creator name, accept button
  • Follow expired link → expired message (no accept button)
  • Follow revoked link → revoked message, distinct from expired
  • Accept invitation → pending member created, admin notification enqueued
  • Owner confirms pending member → member goes active
  • Owner dismisses pending member → member removed
  • Admin approves pending member → owner confirmation email enqueued
  • Non-owner attempts to generate/revoke → 403

Also in this branch (beyond the invitation workflow)

Some general UI groundwork landed here alongside the invitation work. Rather than splitting it into a separate PR, it's folded in — each piece is small and they build on each other:

  • Site nav bar and HTML user show view (c204d71)
  • Collection and core file show pages with linked index titles (6d96906)
  • Welcome page Projects/Collections buttons point at their index pages (e250fc4)
  • Project titles on the projects index link to the project page (ae73f52)
  • Cascade-destroy collections and core files on project deletion, plus an owner-only delete button on project/collection pages that reports the collection and file counts before confirming (589065e)
  • Fixed a bug caught in manual testing: Project has_many :project_invitations was missing dependent: :destroy, so deleting any project that had ever generated an invitation link (even a revoked one) hit a foreign key error and rolled back the entire deletion (417c29b)
  • Rolled back collection-scoped project member permissions per the strategy group's decision — back to a single project-wide contributor role, with a future project-level classroom-mode toggle as the real fix for restricting contributors from editing each other's files (b7e0b0d)
  • Manual-testing layer described below (53ac334) — includes a fix worth calling out: the layout never rendered flash messages, so every success/error notice in the app (this workflow's included) was silently dropped
  • Bumped msgpack to clear a bundler-audit security advisory that was failing CI (1bcd116)

Manual testing in the UI

This branch now includes a thin manual-testing layer, so everything above can be clicked through in a browser (the designed frontend is still in wireframes — these forms are plain placeholders meant to be replaced).

Setup: start Solr, then bin/rails db:seed and bin/rails server. Seeds are safe to re-run and print an active invitation link when they finish. All seeded accounts use password password123:

Login What it's for
owner@example.com Owns the demo projects — generate/revoke invitation links, confirm/dismiss pending members
contributor@example.com Contributor on both demo projects; owns one of their own
outsider@example.com No memberships — use to accept the invitation link as an existing user
pending-vetting@example.com Already pending, waiting in the admin review queue
pending-owner@example.com Already pending, waiting on owner confirmation
admin@example.com Admin panel + membership review queue

Manual test pass:

  1. Start Solr (bin/solr start, or brew services start solr if installed via Homebrew) and confirm it's up at http://localhost:8983/solr/#/tapas-core before seeding — Projects/Collections/CoreFiles index themselves on save and seeding will fail if Solr isn't reachable.
  2. Sign in as owner@example.com, open the Public Demo Project page, copy the invitation link under "Invitation Links."
  3. Sign out, open the link in a private window, sign up with a brand-new account.
  4. Accept the invitation — confirm the message says the request is pending admin review (new-to-TAPAS path).
  5. Sign in as admin@example.com, go to /admin/project_members/review_queue. Confirm the new registrant shows up, then click "Approve."
  6. Check for the owner-confirmation email — letter_opener (not letter_opener_web) is what's installed, so there's no browsable inbox route; it auto-opens each email as an HTML file in your OS's default browser the moment it's delivered. If you miss that window, the same file is saved under tmp/letter_opener/<timestamp>/plain.html.
  7. Sign in as owner@example.com, confirm the new member from the "Pending Members" list on the project page — confirm they drop off the list.
  8. Repeat the invite flow signed in as outsider@example.com (an existing user with no memberships — seeds.rb calls this out as the account meant for this path; pending-owner@example.com is already pending on this project and would hit the "already a member" branch instead) — confirm it skips admin vetting and goes straight to pending owner confirmation, and never appears in the admin review queue.
  9. As owner, revoke an active invitation link, then confirm the same link now shows expired/revoked.
  10. Click through the nav bar to check the new show pages (project, collection, core file) — index page titles should link to them correctly.
  11. As owner, click "Delete Project" on a project with some collections/files — confirm the prompt shows the right collection/file counts before deleting, and that the delete now succeeds (see the dependent: :destroy fix above — without it this 500s).

Also in this layer: browser forms for creating/editing projects, collections, files (with TEI upload), and profiles; an HTML My TAPAS dashboard; flash messages now render (the layout was silently dropping every notice/alert, including this workflow's); and delete/dismiss buttons redirect with a confirmation message instead of leaving the page frozen. JSON responses are unchanged — browser requests just get redirects and flashes now.

@candyhazlett
candyhazlett marked this pull request as draft June 25, 2026 22:05
@candyhazlett
candyhazlett force-pushed the membership-invite-workflow branch from 99d7973 to e41a1e8 Compare June 25, 2026 22:31
Adds a status enum (pending: 0, active: 1) defaulting to active so
existing direct-add memberships are unaffected. Enforces one-way
transition — active members cannot be moved back to pending.
…mber lockout

Adds the database table and model for project invitations — each invitation
gets a unique token, expires after 90 days, and can be revoked. Members who
join via invitation start as pending and have no project access until confirmed.
- Routes: nested project_invitations (create/destroy), top-level
  invitation show/accept by token, project_members#confirm member
  action, admin project_members#approve member action
- ProjectInvitationsController: owner creates invitation (returns
  token + URL), owner revokes by setting revoked_at
- InvitationsController: show renders invitation page with usable?
  check; accept creates pending ProjectMember and emails admin for
  vetting; unauthenticated users are redirected to sign-in with
  return location stored via Devise
- ProjectMembersController#confirm: owner activates a pending member
- Admin::ProjectMembersController#approve: admin activates a pending
  member and triggers owner_confirmation_request email
- InvitationMailer: admin_vetting_notification (to admins on accept)
  and owner_confirmation_request (to project owners on admin approve)
  with HTML + text templates
@candyhazlett
candyhazlett force-pushed the membership-invite-workflow branch from e41a1e8 to c9585bd Compare June 25, 2026 23:04
- ProjectsController#show: renders project management page with active
  invitation links and pending members list (owner-only sections)
- ProjectInvitationsController: add HTML format (redirect to project show)
  alongside existing JSON API responses
- ProjectMembersController#confirm: same HTML/JSON split
- Project: add has_many :project_invitations association
- invitations/show: add invitation creator name; distinct expired vs.
  revoked messaging
- projects/show.html.haml: new project management page with generate-link
  button, active invitations list with revoke, pending members with
  confirm/dismiss
- Full request spec suite: invitations (show + accept all paths), project
  invitations (create + destroy), project members confirm, admin approve;
  regression that direct-add via create produces active status
@candyhazlett candyhazlett changed the title Invitation workflow: data layer, routes, controllers, and mailer Invitation workflow: data layer, routes, controllers, mailer, views, and request specs Jun 26, 2026
Admin#approve was prematurely setting status to :active, causing the
owner's confirm action to fail its pending? guard. Activation now
happens solely in ProjectMembersController#confirm as intended.
Required for invitation acceptance: without this, Turbo-driven requests
receive a 401 instead of being redirected to sign-in with the return URL
stored for post-login redirect.
Ensures the two-step sequence works end-to-end: member stays pending
after admin approval, then becomes active only on owner confirmation.
Existing TAPAS members accepting an invite now go straight to owner
confirmation; only accounts registered during the invite flow route
through admin vetting first. Also fixes the admin-vetting email lookup
(nonexistent account_type column) and the post sign-in/up redirect,
which previously sent unauthenticated users back to a POST-only route.
Persists which pending members actually need admin vetting
(needs_admin_vetting) instead of relying on a transient session flag, and
gives admins a dedicated review queue to act on it — previously there was
no UI path to the approve action at all.

Also adds admin-side visibility into active invitation links (parity with
what a project owner can already do), scoped to a narrow read + revoke
view rather than a full Administrate resource, since the invitation token
is a live unhashed credential that shouldn't be exposed via generic CRUD.
libmagickwand-dev only provides headers for compiling native gems;
mini_magick shells out to the imagemagick CLI at runtime, which was
missing from the test job's package list.
Project#destroy previously left orphaned collections behind, and
has_many :through doesn't cascade dependent: :destroy to the far
side, so collection destroy never cleaned up core files either.
Added before/after_destroy callbacks on Collection that destroy only
core files left with zero remaining collections, so files still
owned by a surviving collection are untouched.

Also adds an owner-only delete button on project/collection show
pages with a confirmation prompt that reports the collection and
core file counts before the cascade runs.
Documents the resolved collection-slugs-url-structure decision:
record URLs are not collection-scoped, so a core file belonging to
multiple collections in the same project still resolves via a single
/core_files/:id URL with no ambiguity about which collection wins.
Rolled back per the strategy group's decision: a single project-wide
contributor role is the model going forward, with a future
project-level classroom-mode toggle (not per-collection scoping) as
the real fix for restricting contributors from editing each other's
files. No UI ever shipped for assigning scopes, so this only removes
the model, ability rules, and the params/spec coverage behind them.

Drops the project_member_collection_scopes table via a new migration
rather than editing the original create-table migration.
Until the designed frontend lands, the app needs enough plain UI to
click through features by hand. This adds:

- letter_opener in development, so emails (like invitation
  confirmations) open in a browser tab instead of vanishing into logs
- seed data with six known logins (password123), demo projects,
  collections, TEI files, an active invitation link, and two pending
  members frozen mid-invite so the review flows can be tested
- simple throwaway forms for creating and editing projects,
  collections, files (with TEI upload), and user profiles, plus an
  HTML version of the My TAPAS dashboard (was JSON only)
- flash messages now actually show up - the layout never rendered
  them, so every success/error notice was being silently dropped
- delete buttons now redirect somewhere sensible with a confirmation
  message instead of leaving the page frozen

Browser requests get redirects and flash messages; JSON requests
behave exactly as before.
bundler-audit was failing CI over CVE-2026-54522 in msgpack 1.8.1
(a memory bug that could leak data between buffers). Bumping to
1.8.3 clears it. msgpack is an indirect dependency - nothing in
the app uses it directly.
@candyhazlett
candyhazlett marked this pull request as ready for review July 2, 2026 19:04
The comment said this email only goes out after admin approval, but
since the vetting split it's also sent straight away when an existing
user accepts an invitation.
Users aren't required to set a name, and the owner confirmation email
subject read "Please confirm 's membership" for anyone who hadn't.
Now it uses their email address instead, matching how names are shown
everywhere else in the app.
@candyhazlett
candyhazlett requested a review from amclark42 July 14, 2026 16:02

@amclark42 amclark42 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks pretty good!

Code review was not requested. My review is confined to the steps listed in "Manual test pass" section, plus "what happens if I do this" exploration.

  • Project owner can approve existing TAPAS users as project contributors, these do not require site admin vetting
  • Site admin can approve new TAPAS users who create accounts as part of a project invite
  • Project owner gets email on invitation request from a TAPAS user (new or existing)
  • Project owner gets email when site admin approves a new TAPAS user
    • This is a second email re: a fresh user
    • The project owner does not get a followup email when a site admin vetoes a new user
      • If the owner follows the link in their first email to confirm the deleted user: No route matches [GET] "/projects/153/project_members/908/confirm"
  • Project owner can revoke an invitation link
    • An informational alert appears at the top of the page and the invitation link disappears
  • Site admin can revoke an invitation link
    • The project owner gets no notification that this happened
  • Someone accessing a revoked invitation link gets a page "This invitation has been revoked. Please contact the project owner if you believe this is a mistake."
    • There is a bug in the caching here. I clicked the "TAPAS" link (http://localhost:3000/).
      • When I signed in as admin, I was redirected to the revoked invitation URL ("This invitation has been revoked")
      • I signed out and created a new user, and after that user logged in, they also got redirected to the revoked project invitation, even though that URL should not have been anywhere near my history
      • This feels like a client-side, Javascript issue rather than a server-side, Rails one. It's probably not worth fixing now, because the site assets will be minimal in Javascript use
  • Projects, collections, and TEI files have stub landing pages
  • A project owner can delete a collection or a project, and all contents are deleted
  • A new TAPAS user, not coming from an invite link, is not flagged for site admin review

I think the "Membership Review Queue" and corresponding routes may need tweaking. The site admin doesn't need to vet project membership, they need to vet new users (who incidentally may come in from project membership invites). To that end, the project owner probably doesn't need to know if an incoming member has been vetted by a site admin. From the site admin perspective, the only question at stake is: "Is this a real TAPAS user?" But for a project owner, the question is: "Is this someone I intend to join my project?" (The site admin can't vet that.)

Accessibility issues (click this heading to open if interested)

I noticed a smattering of accessibility barriers in the UI. I'm not sharing these with the expectation that you'll fix them in the TAPAS UI, I'm just letting you know about these issues so you can learn and build that knowledge into your other projects.

  • Links should look like links. A lot of the ones I'm seeing look like buttons instead. That will lead to confusion about what something will do. (WebAIM guidance)
  • On the site admin page, the links in the sidebar are at very low color contrast: #adb5bd is 2.07 to the white background's 1. To meet WCAG level AA, you'll need the ratio to be 4.5 to 1. (WebAIM guidance) Browsers' developer tools will usually tell you what the ratio is, and whether it meets WCAG recommended levels. (Here's how to do it in Firefox; Chrome-based browsers have a similar feature.)
  • Something on the page changes dynamically, screen readers need to be cued in. For me, this means (1) being selective about what page content changes occur, and (2) when it would be useful to have those changes, use ARIA live regions to politely notify screen readers about them. (MDN guidance) So, for instance, I might make "Generate invite link" into a link to a new page, but if that turned out to be too onerous for the user, I would make sure the Javascript updates a live region on the page to let screen readers know that there is now a link available on the screen.
  • Speaking of dynamic changes, it's a good idea to watch for unexpected "changes of context" — times when the user's focus is shifted elsewhere. (An element that has "focus" is one that can be manipulated by the keyboard.) If user is moved somewhere they weren't expecting, it can be disorienting. For instance, when I revoke a project invitation link as the project owner, a status message appears at the top of the screen, the top of the page scrolls into view, and my focus is moved to the top of the page — an unexpected change of context. This is distracting visually but it's particularly annoying for keyboard users. To get back to the "Invitation Links" section, I have to tab past all the links and buttons that appear before that section. A better solution would be to have the alert appear under the "Invitation Links" heading, as close to the user's focus as possible, and not to move the user around the page at all. (WebAIM guidance)
  • There should be a link to skip past the site navbar to the main content. I remember you've made "skip to" links before, so I wasn't going to bring this up. But to get back to the "Invitation Links" section via keyboard unfortunately means tabbing past all the links in the header for a second time, so I wanted to stress that having a method to skip them matters.
  • Some buttons have suppressed their focus indicators, so keyboard users don't know where they are. For example, the "Submit" buttons in the Admin Dashboard, don't show an outline when I tab to one. The .btn class appears to be suppressing the outline CSS property. (WebAIM guidance)

Future work (not for this PR)

  • Email notifications for significant events
    • Admin deletes a new user who wanted to join a project (for project owners)
    • Admin revokes a project invite link (for project owners)
    • A project is deleted (for all project members)
  • Content of emails
    • Need to build in user-friendly explanations of the user creation workflow
  • Should we allow multiple invitations to be generated for a single project? (I thought we'd said 1 per project, but the Strategy meeting notes don't show a decision.)
  • The page at the invitation URL should probably link to the project, or display more information about it
    • Ditto the project member confirmation and vetting steps. The email address alone (e.g. xXxMyPersonalEmailxXx@example.com) may not tell me who this person is
  • Projects will need a route for listing/managing project membership. There's currently no way for an owner to elevate another owner
  • Project owners will need a way to invite an existing TAPAS user

The "Confirm Membership" link in the owner's email only worked if
JavaScript on our own site rewrote the click into the right kind of
request - something that never happens inside an actual email client.
So clicking the link straight from an email always failed with a
broken-page error, for every owner, every time, whether or not an
admin had rejected anyone.

Now the email links to a plain page with a real button that performs
the confirmation, the same way the "Confirm" button already works
elsewhere in the app. If the membership request has since been
removed, that page now shows a plain message instead of an error.

Also adds a committed browser-testing script for this flow
(spec/playwright_regression/pr_112_invitation_flow.md) so this can be
re-checked automatically instead of relying on someone clicking
through it by hand.
New accounts now wait for admin approval before they can log in. The
review queue used to live under memberships — it's now the account
review queue.
…ting

Seeds still referenced a column we dropped, so seeding a fresh dev
database would have failed. The Playwright test script also still
described the old per-membership vetting flow and the queue page that
moved.
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.

2 participants