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

> `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.

<!-- id: security-invitations-kit · area: security · stability: preview · html: https://osysharp.com/reference/security/invitations-kit/ -->

## Summary        {#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:

```osy syntax
policy MayInvite => IsAdmin;
```

## Signature      {#signature}
```osy syntax
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    {#description}

### Sending                                    {#sending}
`Invite("ada@studio.com", 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](https://osysharp.com/reference/security/organizations-kit/)).
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         {#lifecycle}
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)](https://osysharp.com/reference/workflow/remind/), [Callback URLs — letting an outsider complete one slot](https://osysharp.com/reference/workflow/callback-url/)).

### Who answers it                              {#answering}
**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         {#guarantees}
- 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      {#accounts}
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       {#examples}

### A studio that is invite-only               {#example-studio}
```osy title="sign-up closed, the roles offered, invitations on a page only owners see" test app=security-invitations-kit
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       {#see-also}
- [signup by invitation (invite, accept link, chase, expire)](https://osysharp.com/reference/security/invitation-signup/) — how an invitation's run is built, from the workflow up
- [The Organizations kit — companies, teams, and roles held within one](https://osysharp.com/reference/security/organizations-kit/) — inviting into an organisation, with a role held there
- [RoleChoice.HeldIn — where a role may be held: across the app, inside an organisation, or either](https://osysharp.com/reference/security/roles-held-in/) — which roles the form offers on an app-wide invitation and on one into an organisation
- [The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it](https://osysharp.com/reference/security/people-kit/) — the people manager, which invites from the same form
- [The UserAccounts kit — sign-in done properly, once](https://osysharp.com/reference/security/accounts-kit/) — sign-in, and closing sign-up
