Skip to content

Lists: invite people to a list by email (#55) - #168

Merged
Adron merged 2 commits into
mainfrom
issue-55-list-invites
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-55-list-invites

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Adds the invite-by-email path that the web Share window now leads with: give a
specific person — who may not have an account yet — a role on a list while the list
stays private. Distinct from the two sharing paths already shipped: a share link is a
bearer capability (whoever holds the token has access) and a watcher must already be an
InterlinedList account.

Branch / file placement

Services/InterlinedApiClient.Lists.cs is in unmerged PR #143 and Views/ListsView.xaml
is in the #161/#163/#164 stack, so this branches from main and puts the service in a
new domain partial — Services/InterlinedApiClient.ListInvites.cs. Partial-per-domain
is this repo's stated convention, so that's idiomatic rather than a dodge, and .Lists.cs
is not touched at all.

ListsView.xaml takes exactly one functional line (plus a comment):

<local:InvitePanel Kind="List" TargetId="{Binding SelectedList.Id}" Margin="0,0,0,12"/>

That was possible because the panel takes its target declaratively (Kind +
TargetId dependency properties) and news up its own ViewModel from AppServices, so
there is no code-behind edit in ListsView.xaml.cs and no change to
ListsViewModel.cs (which is on the do-not-touch list anyway).

Live verification (2026-09-16)

Probed with a bearer sync-token against a throwaway list I created and then deleted —
never the account's own New list.

GET    /api/lists/{id}/invites          -> 200 {"invites":[]}
POST   /api/lists/{id}/invites          -> 201 {"email":"…","role":"collaborator",
                                                 "expiresAt":null,
                                                 "url":"https://interlinedlist.com/lists/invite/<token>"}
GET    /api/lists/{id}/invites          -> 200 {"invites":[{"email":"…","role":"collaborator",
                                                 "expiresAt":null,"accepted":false,
                                                 "createdAt":"…","token":"<token>"}]}
DELETE /api/lists/{id}/invites/{token}  -> 200 {"revoked":true}
GET    /api/lists/{id}/invites          -> 200 {"invites":[]}

Error paths, also live:

POST   … {"email":"not-an-email"}  -> 400 {"error":"A valid email address is required"}
POST   … {"role":"bogus"}          -> 400 {"error":"Invalid role. Must be watcher, collaborator, or manager"}
DELETE …/invites/<unknown>         -> 404 {"error":"Invite not found or access denied"}

Two findings worth knowing, both encoded in the models:

  1. The envelopes are asymmetric. The create response carries url but no token;
    the listing carries token but no url. Revoke is keyed by token, so creating is
    followed by a read-after-write GET. EmailInvite.Token/Url are therefore both
    nullable, and the row rebuilds the landing URL from the token for "Copy link".
  2. role works even though the published OpenAPI body schema omits it (that schema
    lists only email + expiresAt). It is accepted and round-trips for all three values,
    matching /help/api/sharing. The response envelope is live-verified, so
    CreateListInviteAsync deserializes it — but the panel still re-reads the GET, per this
    repo's read-after-write rule and because it needs the token.

GET /api/lists/{id} was also confirmed to carry userId in its data envelope, which
is how ownership is resolved without modifying ListSummary (a Models/* file I must not
touch, and which doesn't model that field on main).

What I deliberately did not verify

  • No invite was sent to any real address. Every probe used
    claude-probe@il-probe.invalid — .invalid is an IANA-reserved TLD that can never
    resolve, so the server's fire-and-forget invite email cannot reach a person. Each invite
    was revoked immediately after its shape was captured.
  • The claim/accept side is unverified and not implemented here (GET/POST /api/lists/invite/{token}). Claiming is session-cookie-only per the API docs — a
    bearer-token native client structurally can't do it — and it needs a second verified
    account. That's the rest of epic Epic: Sharing, invites and collaboration depth #54, not this issue.
  • An undocumented notify: false on the invite create is accepted (201) but not
    echoed
    , so I could not confirm it suppresses the email and did not expose a tick
    for it. The "Email this person" tick in the web belongs to the named-person
    (watcher/collaborator) flow, where notify is documented — for an email invite the
    email is the delivery mechanism. Wiring notify into the existing
    AddListWatcherAsync would mean editing the contended .Lists.cs, so it's left for a
    follow-up.

Cleanup confirmed

The throwaway list ZZ claude-probe invites 55 was deleted, and a closing
GET /api/lists returns {"lists":[{"title":"New list"}],"pagination":{"total":1}} — the
account's own content is untouched.

Product behaviour encoded (from /help/documents + /help/api/sharing)

Three rules are enforced in the UI rather than left to a server error:

  • Owner-only. Only the true owner can invite, re-role or remove — even an Admin
    (manager) collaborator cannot manage sharing.
    The panel resolves the owner id from
    the resource payload and gates on it; a non-owner sees a plain notice, and the server's
    own answer for a non-owner is 404 (existence is never leaked), which the panel
    swallows rather than surfacing as an error.
  • Subscriber-gated, locked not hidden. A free account sees the invite form visible
    and disabled
    with an amber Upgrade prompt, matching the web. Gate is
    customerStatus != "free" — authoritative per /help/api, where the values are free,
    subscriber, subscriber:monthly, subscriber:annual and any non-free value grants
    access. The invitee never pays.
  • Revoke is not subscriber-gated. A lapsed owner can always shut off access they
    granted, so Revoke stays live while the send form is locked.

Roles use the product's labels over the wire values: Read-only (watcher) / Edit
(collaborator) / Admin (manager), each with the one-line "can do" description from the
help centre. Expiry offers Never / 7 days / 30 days, and the pending row shows
address · role · Pending-or-Accepted · expiry, exactly as the web's "Pending invites" does.

What still needs wiring

Notes for review

  • No ComboBox: the app has none anywhere (native combo chrome doesn't follow the Strata
    themes), so both pickers are segmented buttons driven by an option's IsSelected rather
    than by RadioButton grouping — grouping is visual-tree scoped and would misbehave with
    the Lists and Documents panels both alive in the MainWindow view cache.
  • Strata tokens only (PrimaryBrush, AmberBrush for the locked state, Surface*,
    Border*, Text*), 4px card corners / 3px rows, 4pt spacing. The one literal is the
    error red #FFE81123, copied from the existing error banners in ListsView for
    consistency.
  • No blocking native dialogs; all feedback is inline status/error text.
  • Builds clean in both -c Debug and -c Release (-r win-x64, 0 warnings).
  • Selection changes are generation-stamped so a slow load for a previously selected list
    can't paint over a newer one.

Closes #55

🤖 Generated with Claude Code

Adds the invite-by-email path the web Share window leads with: a specific
person (who may not have an account yet) is granted a role while the list
stays private, unlike a share link (a bearer capability) or a watcher (an
existing account).

Service lives in a new domain partial, Services/InterlinedApiClient.ListInvites.cs,
rather than in .Lists.cs — partial-per-domain is this repo's convention and it
keeps the contended file untouched.

All shapes verified live 2026-09-16 against a throwaway list, then cleaned up:

  GET    /api/lists/{id}/invites          -> 200 {"invites":[{email,role,
                                                   expiresAt,accepted,createdAt,token}]}
  POST   /api/lists/{id}/invites          -> 201 {email,role,expiresAt,url}
  DELETE /api/lists/{id}/invites/{token}  -> 200 {"revoked":true}

Note the asymmetry the models capture: the create response carries `url` but no
`token`, and the listing carries `token` but no `url` — so creating is followed
by a read-after-write to get the token Revoke needs. `role` is accepted and
round-trips (watcher/collaborator/manager) even though the published OpenAPI
body schema omits it; an invalid role is 400, an invalid address is 400, and an
unknown token is 404.

The UI is one reusable control, Views/InvitePanel, driven by IInviteTarget so
issue #56 can host the same card over /api/documents without duplicating it.
Hosting it costs ListsView.xaml exactly one line, because the panel takes its
target declaratively (Kind + TargetId) and owns its own ViewModel.

Product rules encoded rather than left to a server error:
- only the true owner can manage sharing — not even a manager-role
  collaborator — so the panel resolves the owner id off the list payload and
  gates on it;
- inviting is subscriber-only, so a free account sees the controls locked with
  an Upgrade prompt instead of hidden, and the invitee never pays;
- revoking is deliberately NOT subscriber-gated, so Revoke stays live even
  while the send form is locked.

Closes #55

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron merged commit 8f4852c into main Sep 24, 2026
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.

Sharing: invite people to a list by email

1 participant