feat(documents): email invites — send, list and revoke (#59) - #102
Merged
Merged
Conversation
Adds the email-invite flow to the document access sheet, alongside the
existing viewer/editor/admin collaborators rather than replacing them:
- POST/GET /api/documents/{id}/invites and DELETE .../invites/{token}
plumbed through DocumentsRepository as sendInvite/getInvites/revokeInvite.
- An "Invite by email" form (address + Viewer/Editor/Admin role) and a
"Pending invites" list showing role, derived status (Pending / Accepted /
Expired / Revoked) and expiry, each row revocable.
- Sending is gated on CustomerStatus.isSubscriber via the shared
GET /api/user check, so a free account issues no write at all; listing
and revoking are never gated, matching the server.
- Addresses are validated client-side before any request, and server
rejections are surfaced with the server's own message instead of a
generic failure.
Invite roles use the server's sharing vocabulary (watcher / collaborator /
manager) behind the web app's Viewer / Editor / Admin labels, and the
create response's missing token is recovered from the returned landing URL
so a just-sent invite can be revoked.
Accepting an invite (/api/documents/invite/{token}) is deliberately out of
scope; it is covered by #60 for lists and documents together.
Closes #59
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #59. Part of epic #57. Sets the pattern #58 mirrors for lists.
What changed
Email invites now sit alongside the existing collaborator roles on the Manage-access sheet: an
Invite by email form (address + role) and a Pending invites list showing role, status and
expiry with a per-row Revoke.
The contract came from
/help/api/sharing→ Email invites, and it differs from the issue textPOST /api/documents/{id}/invites{ email, role, expiresAt }roleiswatcher|collaborator|managerviewer/editor/admin{ email, role, expiresAt, url }token— the token is recovered from the landing URL's last path segment, without which you cannot revoke an invite you just sentGET .../invites{ "invites": [ { email, role, expiresAt, accepted, createdAt, token } ] }statusfieldaccepted/expiresAt/revokedAtDELETE .../invites/{token}{ "revoked": true }, explicitly not subscriber-gatedDocumented rejections: 400
bad_request, 403 for a free owner ("Subscribe to invite people todocuments."), 404 for a non-owner, 429 rate-limited. Re-inviting the same address is idempotent
— so "already invited" is not an error, and the UI replaces the existing row rather than showing a
failure.
The list and create projections differ, so
DocumentInviteDtoaccepts both (acceptedandacceptedAt, plus theDocumentShareInviteschema'sid/documentId/invitedByUserId/revokedAt/acceptedByUserId), and the envelope accepts inline,{invite:…}and{data:…}forms.Subscriber gate
Reuses the established pattern —
getCurrentUser()→customerStatus.isSubscriber— checked insidesendInvitebefore the POST, returningAppError.SubscriptionRequired. Revoking is not gated.It fails open when
/api/useris unreadable, so a flaky lookup cannot block a paying subscriber;the server stays authoritative.
Verification
./gradlew :app:assembleDebug testDebugUnitTest→ BUILD SUCCESSFUL, 843 tests, 0 failures (37new). Coverage includes 11 MockWebServer round-trips asserting exact paths and bodies, a
free-account send that makes only the
/api/userGET and no POST, an invalid email that makeszero requests, and a revoke that does no subscription lookup at all.
Reviewer note — a separate bug this work uncovered
The existing
CollaboratorRolesendsviewer/editor/adminto/collaborators, but the server'svocabulary is
watcher/collaborator/manager. I confirmed on the live API thatPOST /api/documents/{id}/collaboratorsdoes not validate the role and silently falls back towatcher— so every collaborator added from Android is read-only regardless of the role picked.Filed as #101; deliberately not fixed here, as it is outside this issue.
Notes for #58
Copy wholesale, changing only
documents→lists:DocumentInvite.kt(theInviteStatus/InviteRole/InviteEmailtrio is domain-identical — the help centre says the twoforms differ only in the resource path and the claim response's id key),
InviteDtos.kt,InviteMappers.kt,InviteLabels.kt, and thePendingInviteRow/InviteByEmailSectioncomposables.Mirror the repository shape (
getInvites/sendInvite(id, email, role)/revokeInvite(id, token),with the gate and email guard inside
sendInviteso no caller can bypass them) and the nestedUI-state object, which drops into the lists access screen unchanged.
Per repo convention each feature module is self-contained, so this is deliberate duplication. If it
becomes annoying,
InviteStatus/InviteRole/InviteEmail/inviteExpiryLabelare the onlyresource-agnostic pieces and would be the candidates to lift into
:core:.Known limitation
expiresAtis always sent asnull(no expiry) — there is no expiry picker. The field is plumbedthrough the request DTO, so adding one later is UI-only. Sending costs one extra
GET /api/userperinvite; a cached
customerStatuswould remove it, but no such cache exists today.