Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

The Invitations kit — invite an address with a role, answered only by that address

use Osysharp.Invitations@2; · policy MayInvite => IsAdmin; · Invite(email, AppRole.Agent, organization: acme) · InviteForm(canSend: MayInvite)

`Osysharp.Invitations` brings a person into an app by their address, with what they will be — an app role, or a membership and a role held in one organisation — fixed when the invitation is sent. It is mailed, reminded, lapses, and can be revoked or resent, all as one durable run; and it is answered only by the invited address — a newcomer through the single-use mailed link, somebody with an account by signing in (every step) and accepting.

preview1 example compiled by CIsecuritykitinvitationsworkflow

Source on GitHub: Osysharp.Invitations — github.com/osysharp/invitations ↗

Summary#

use Osysharp.Invitations@2; (with Osysharp.UserAccounts and Osysharp.Organizations) gives an app Invite, a mailed invitation with reminders and expiry, revoke and resend, the /invite/{token} page the mail links to, and two components for the people who invite. The app answers one question — who invites — and says how its roles read:

policy MayInvite => IsAdmin;

Signature#

policy MayInvite;                                             // the app supplies it
[Authority(Extensible)] public entity Invitation {
  string Email; role? Role; Organization? Organization; string? Note; Principal InvitedBy;
  TimeSpan ValidFor; TimeSpan RemindAfter; TimeSpan RemindEvery;
  DateTime? SentAt; int Sends; int Reminders; DateTime? ExpiresAt;
  InvitationStatus Status;   // Pending | Accepted | Revoked | Expired
  Principal? AcceptedBy; DateTime? AcceptedAt; Principal? RevokedBy; Principal? LastSentBy;
}
Invitation Invite(string email, role? role = null, Organization? organization = null, string note = "",
                  bool anotherAddress = false);   // Invitation.AnotherAddressAllowed, fixed at send
void RevokeInvitation(Invitation invitation);       void ResendInvitation(Invitation invitation);
List<Invitation> MyInvitations();                   void AcceptInvitation(Invitation invitation);
[AllowAnonymous] string JoinByInvitation(string token, string email, string name, string password);
component InviteForm(Organization? into = null, Action onSent = null, bool canSend = true);
component InvitationList(Organization? into = null, bool canChange = true);
component InvitationPage(InvitationKey invitationKey, string token);   // /invite/{invitationKey}/{token} — shows the invited address
grant InvitationKey for Invitation;   grant AddressProof for InvitationAddress;
[AllowAnonymous] InvitedView InvitationByKey(InvitationKey key);                  // the address, read from the link
[AllowAnonymous] string AskToJoinAs(InvitationKey key, string token, string email); // mails the other address a link
[AllowAnonymous] string JoinByInvitationAs(AddressProof proof, string token, string name, string password);

app.Invitations = new InvitationsSetup { ValidFor = …, RemindAfter = …, RemindEvery = …,
  Mails = new InvitationMails(), Accounts = new InvitationAccounts() };
app.Roles = new RolesSetup { Offered = [ new RoleChoice { Role = AppRole.X, Label = "…", Description = "…" } ] };

Description#

Sending#

Invite("[email protected]", AppRole.Designer) saves an invitation and starts its run, which mails the link. With organization: acme it invites into that organisation, with the role held there (The Organizations kit — companies, teams, and roles held within one). Pressing it twice sends one invitation, and inviting a person to something they already hold is refused with a sentence to show. InviteForm offers the roles app.Roles lists, by the names people read; a role not listed is never offered.

Reminded, lapsing, revoked, resent#

An invitation not answered is reminded after RemindAfter, then every RemindEvery, and lapses after ValidFor — with a last mail saying so. RevokeInvitation withdraws it; ResendInvitation sends a new link and starts the time again. Each is one durable run, so a restart in between loses nothing (Remind (milestone reminders), Callback URLs — letting an outsider complete one slot).

Who answers it#

Only the address it was sent to, through one of two doors:

  • Somebody new opens the mailed link and makes their account. The link works once, is stored only as a hash, stops working when the invitation is revoked, resent or lapses, and makes an account for the invited address and no other — proven, since the mail was opened. Signing in is then the ordinary sign-in, two-step included. The page SHOWS the invited address, read on the server from the link's InvitationKey; nobody types it.
  • Another address, when the inviter allowed it. Sent with anotherAddress: true ("They may join with another address" on InviteForm, off by default), the page offers "Join with another address": AskToJoinAs mails that address a confirmation link carrying an AddressProof, and only that link makes the account — for the confirmed address. A forwarded invitation link alone still cannot make an account for a stranger.
  • Somebody with an account signs in — every step — and accepts in the app (MyInvitations, AcceptInvitation). Only an account whose address is the invited one, and proven, may: an account a stranger made with somebody else's address cannot accept that person's invitation. The link does not accept for an existing account — holding a mailbox is not holding the account.

What holds whatever the app grants#

  • What an invitation grants is fixed at send: its address, role and organisation are never edited.
  • It never carries the sign-in flow's Authenticator role, and its inviter is whoever sent it.
  • Accepting grants exactly what was sent — the app role, or the membership and the role held in the organisation — once.
  • Invitation is [Authority(Extensible)], so an organisation's own admin may be allowed to invite into it, as an ordinary grant: partial entity Invitation { security { allow read, create, update when IsAuthenticated where Organization != null && HoldsRoleIn(Organization, AppRole.CompanyAdmin); } }.

What kind of account a newcomer gets#

By default the app's own sign-up kind (AccountsSetup.Kinds). When the invitation says more than the address does — somebody invited as an agent from a contractor's domain is still an agent — derive InvitationAccounts, override New(invitation, email, name), and set Accounts on app.Invitations. Derive InvitationMails to write the mails — or override only its Postscript(invitation) to add one paragraph under the button of the invitation and its reminder (a welcome, where to ask for help, a link to the app's community); it answers "" by default, which adds nothing.

Examples#

A studio that is invite-only#

app Studio {
  use Osysharp.Ui;
  use Osysharp.Workflow;
  use Osysharp.Identity@0;
  use Osysharp.Mail@0;
  use Osysharp.EmailTemplates@0;
  use Osysharp.UserAccounts@1;
  use Osysharp.Organizations@0;
  use Osysharp.Invitations@2;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.UserAccounts;
using Osysharp.Invitations;

[Role] enum StudioRole { Authenticator, Designer, Owner }
policy IsOwner => RoleGrant.Any(g => g.Member == user && g.Role == StudioRole.Owner);
policy MayInvite => IsOwner;
policy ManagesOrganizations => IsOwner;

app.Accounts = new AccountsSetup { AppName = "Studio", SignupOpen = false, FirstAccountRole = StudioRole.Owner };
app.Roles = new RolesSetup { Offered = [
  new RoleChoice { Role = StudioRole.Designer, Label = "Designer", Description = "Works on the studio's projects." },
  new RoleChoice { Role = StudioRole.Owner, Label = "Owner", Description = "Invites people, and runs the studio." },
] };
app.Invitations = new InvitationsSetup { ValidFor = TimeSpan.FromDays(5), RemindAfter = TimeSpan.FromDays(2) };

Invitation InviteDesigner(string email) { return Invite(email, StudioRole.Designer, note: "Welcome to the studio."); }

[Route("/")]
[Title("People")]
component PeoplePage() {
  render {
    Stack(gap: 5, p: 5, canSee: MayInvite) {
      Card("Invite someone") { InviteForm(canSend: MayInvite); }
      Card("Not answered yet") { InvitationList(canChange: MayInvite); }
    }
  }
}

demo/wf-signup-invite is the same shape as a running app, with tests for both doors.

See also#

Related

signup by invitation (invite, accept link, chase, expire)

How an app lets somebody INVITE a person who has no account yet. The invitation is a workflow: it mints a tokenised…

The Organizations kit — companies, teams, and roles held within one

`Osysharp.Organizations` holds the groups people belong to — the companies an app's customers work for and the teams…

The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it

`Osysharp.UserManagement` is an app's user manager: a searchable people list with each person's roles, two-step status…

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…

The Identity kit — who signs in, and which of the app's roles they hold

`Osysharp.Identity` is the contract every people-aware kit builds on: `Principal`, the one `[Principal]` of the app (an…

`role` — a package's name for the app's own roles

A package cannot know an app's `[Role]` enum, and an app has exactly one. `role` is how a package names it anyway: a…