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" onInviteForm, off by default), the page offers "Join with another address":AskToJoinAsmails that address a confirmation link carrying anAddressProof, 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
Authenticatorrole, 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.
Invitationis[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#
- signup by invitation (invite, accept link, chase, expire) — how an invitation's run is built, from the workflow up
- The Organizations kit — companies, teams, and roles held within one — 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 — 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 — the people manager, which invites from the same form
- The UserAccounts kit — sign-in done properly, once — sign-in, and closing sign-up