Skip to content

Let organizations share projects with their clients - #463

Merged
str1fe merged 4 commits into
mainfrom
t3code/shared-projects
Sep 29, 2026
Merged

str1fe merged 4 commits into
mainfrom
t3code/shared-projects

Conversation

@str1fe

@str1fe str1fe commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Why

When our consultants work on a project for a client that also uses Stemplin, the client wants to see the hours and cost we register on it. This lets the supplier share a project with the client's organization, read-only.

How it works

  1. Supplier: a new Sharing tab on /workspace/projects/:id invites an administrator in the client's organization by email. Pending invitations show a "copy link" button; live shares and invitations can be revoked.
  2. Client: the invitation link (/share_invitations/:token) shows what will be shared and lets the invited admin pick which of their organizations the project goes into. Only the address the invitation was sent to can open it. Signing in from the link returns to it.
  3. Client: admins get a Shared projects menu (/shared/projects), shown once something is shared. It lists shared projects per supplier with hours and cost for a period. Each project has a detail page with consultant/task filters, notes, and a CSV export without email addresses. Amounts use the supplier's rates and the supplier's currency. The client can also remove a share.

Only the client's admins see shared projects for now. Spectators/members can be given access later through ProjectAccess without schema changes.

Authorization

Sharing does not widen any existing scope. Shared projects are only reachable through new Shared:: policies (Shared::ProjectPolicy, Shared::TimeRegPolicy, Shared::ProjectSharePolicy), so time registration, reports, the API and the MCP tools behave exactly as before. The owner's side uses Workspace::ProjectSharePolicy. test/policies/shared_project_scopes_test.rb checks that a share leaks into none of the regular scopes and that the client can't change the project or its time.

Data model

New project_shares table: project, organization (the recipient, set on acceptance), invited_by, invited_email, invitation_token, status (pending / accepted / rejected / revoked), expires_at (7 days) and timestamps per transition. Shares are never deleted, only revoked. Partial unique indexes allow one open invitation per email and one live share per organization per project.

Other changes

  • Sign-in return path: after_sign_in_path_for now returns to the stored location, and the onboarding wizard's exits do the same, so an invitation link survives the sign-in or sign-up detour. This applies to every deep link, not just invitations.
  • Tabs after morph: the RubyUI tabs controller now re-renders the active tab after a morphing Turbo refresh. Previously a redirect back to the same URL left every tab hidden. This affected all tabs in the app.
  • Report period navigation: the partials take the path to link to, so the shared pages reuse them. Reports are unchanged.

Invitation email

The invitation is a plain Rails email (app/views/user_mailer/project_share_invitation_email.{html,text}.erb), translated with the other share strings and sent in the invited user's locale. It goes out over the existing SendGrid SMTP relay, so no SendGrid template or environment variable is needed. In development it opens in letter_opener, and there is a mailer preview at /rails/mailers/user_mailer/project_share_invitation_email. The "copy link" button on the Sharing tab also works when email isn't set up.

Not included

Per-share toggles for hiding amounts or notes (notes are always shared), notifications when a share is revoked, and PaperTrail on ProjectShare.

Test plan

  • bin/rails test: 396 runs, 0 failures
  • bin/rails test:system: 8 runs, 0 failures
  • bin/rubocop: no offenses
  • Walked through the full flow in a browser against seeded demo data (invite, sign in from the link, accept, filter, export, overview) and recorded it
  • Sign up as a brand-new user from an invitation link and return to it after onboarding (implemented, not tested yet)

🤖 Generated with Claude Code

str1fe and others added 4 commits September 25, 2026 18:17
A supplier admin invites an administrator in the client's organization
by email from a new Sharing tab on the project. The client picks which
of their organizations the project goes into, and its admins then get a
read-only "Shared projects" menu: hours, consultants, tasks, notes and
cost at the supplier's rates and in the supplier's currency, with a CSV
export that leaves out email addresses. Either side can end the share.

Sharing never widens the app's existing scopes. Shared projects are
only reachable through the Shared:: policies, so time registration,
reports, the API and the MCP tools behave exactly as before.

Also:
- Signing in, or finishing onboarding, returns to the page the user was
  headed to, so an invitation link survives the sign-in detour.
- The RubyUI tabs controller shows the active tab again after a morphing
  Turbo refresh; redirecting back to the same URL left every tab hidden.
- The report period navigation takes the path to link to, so the shared
  pages reuse it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The invitation no longer needs a SendGrid template: it is rendered from
app/views/user_mailer, translated with the other share strings, and
goes out over the existing SendGrid SMTP relay. That drops the
PROJECT_SHARE_INVITATION_*_TEMPLATE_ID variables, works with
letter_opener in development, and lets the tests check the content.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Someone who only belongs to the owning organization has nowhere to put
the project, so the invitation is rejected when it is sent. An invitee
who administers only the owning organization now gets a message saying
so, instead of being told they need to be an administrator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The mailer layout now reproduces the SendGrid template: logo card on a
grey background, centred heading with the accent rule, a primary
button, the Stemplin Support sign-off and the address footer. The logo
is a PNG rendered from the app's SVG, served from our own asset host.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@str1fe
str1fe merged commit 4d86ff2 into main Sep 29, 2026
4 checks passed
@str1fe
str1fe deleted the t3code/shared-projects branch September 29, 2026 08:21
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