wf-signup-invite
An invite-and-accept signup — send an invitation, then wait for the person to accept it.
9 source files2 test files

Get it
$ osy init wf-signup-invite $ osy launch
The app
app.osy9 lines
// An invite-and-accept signup — send an invitation, then wait for the person to accept it. // The ONBOARDING / invitation workflow demo. app WfSignupInvite { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; data "data/**/*.json"; }
model/actions.osy32 lines
// The board's verbs, as ordinary app functions. // Gated by `[Authorize(u => u.Email == this.Item.Email)]` — an IDENTITY comparison. Nobody may accept on anyone // else's behalf, admin included, and that is deliberate: an invitation accepted for you is not one you accepted. // The IN-APP door, for an invitee who is already signed in and therefore already HAS an account. void AcceptInvite(Invitation i) { Onboarding.RaiseAccept(i, null); } void RevokeInvite(Invitation i) { Onboarding.RaiseRevoke(i); } void InviteAddress(string email) { var me = Session.CurrentUser; new Invitation { Email = email, InvitedBy = me }; UnitOfWork.Commit(); } // THE ANONYMOUS DOOR — cf. `AcceptInvite(Invitation)` above, which is the in-app one. Whoever opens the emailed // link has no account yet and cannot sign in, so `[AllowAnonymous]` is not a relaxation here: without it the server // refuses the hand-off from the public accept page, the redeem never runs, and the visitor sees nothing at all. [AllowAnonymous] string AcceptInvitation(string token, string password) { try { Workflow.Redeem(token, "{\"passwordHash\": \"" + Security.HashPassword(password) + "\"}"); return ""; } catch (NotFoundException e) { return "This invitation link is not valid, or it has already been used."; } catch (ConflictException e) { return "This invitation is no longer open — it may have been withdrawn or it may have expired."; } }
model/desk.osy61 lines
// THE DESK — who has not accepted, for how long, how often they have been chased, and whether the invitation ever // missed its promise. /// One invitation as the desk reads it. A `class`, so it is an in-memory projection with no table behind it. class DeskRow { public Guid InvitationId; public string Email; public string InvitedByName; public InviteStatus Status; /// An invitation still waiting has a live clock; an accepted, revoked or expired one does not. Absence, not a zero /// that would draw as a fully-consumed budget and sort the calmest rows where the worst ones belong. public bool HasPromise; public TimeSpan Budget; public TimeSpan Remaining; public DateTime BreachesAt; /// A promise that was MISSED stays missed. When the deadline runs out the engine stamps the breach and RETIRES the /// clock, so a desk ordered on the live figures alone drops the one row everybody needs to see at the exact moment /// it starts mattering. `EverBreached` is read from the audit trail, so it survives the clock being retired. public bool EverBreached; /// HOW MANY TIMES THIS PERSON HAS BEEN CHASED — counted off `AuditKind.Reminded` rows on the run's own timeline. /// A reminder changes no state, so the trail is the only place it is observable, and it is the same artifact ops /// would open to answer the same question. public int Nudges; public DateTime InvitedAt; } /// The desk, on the server. `Workflow.WorkByItem<Invitation>()` is ONE ROW PER INVITATION — the per-slot read would /// be right here too (this workflow waits on one thing at a time), but a board is per item and writing it per slot is /// how a board starts showing an item once per open wait the day a second slot is added. List<DeskRow> Desk() { var rows = new List<DeskRow>(); var work = Workflow.WorkByItem<Invitation>().Include(r => r.Item).ToList(); foreach (var i in Invitation.Include(x => x.InvitedBy).OrderByDescending(x => x.CreatedAt)) { var row = new DeskRow { InvitationId = i.Id, Email = i.Email, InvitedByName = i.InvitedBy.Name, Status = i.Status, HasPromise = false, Budget = TimeSpan.Zero, Remaining = TimeSpan.Zero, BreachesAt = i.CreatedAt, // a zero countdown; every reader gates on HasPromise, never on this date EverBreached = false, Nudges = 0, InvitedAt = i.CreatedAt, }; foreach (var w in work) { if (w.Item.Id != i.Id) { continue; } if (w.BreachesAt != null) { row.HasPromise = true; row.Budget = w.Budget ?? TimeSpan.Zero; row.Remaining = w.Remaining ?? TimeSpan.Zero; row.BreachesAt = w.BreachesAt; } } foreach (var a in Onboarding.For(i).Audit) { if (a.Kind == AuditKind.Reminded) { row.Nudges = row.Nudges + 1; } if (a.Kind == AuditKind.Breached) { row.EverBreached = true; } } rows.Add(row); } return rows; }
model/identity.osy43 lines
// The demo's identity model — WHO the accounts are, and on what authority. [Role] enum AppRole { Authenticator, Member, Admin } entity RoleGrant { [Required] Account Grantee; [Required] AppRole Level = AppRole.Member; security { allow read when IsAuthenticated; allow create when IsAuthenticator; // …the signup, for the first grant allow create, update, delete when IsAdmin; // all three verbs, or the weakest is the way in } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsAdmin => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Admin); [AuthMethod] string Signup(string email, string password) { if (Account.Any(a => a.Email == email)) { return ""; } // taken — sign in, or use your invitation link var a = new Account { Name = email, Email = email, PasswordHash = Security.HashPassword(password) }; var grant = new RoleGrant { Grantee = a, Level = AppRole.Member }; return Security.IssueJwt(a.Id, a.Email); } [AuthMethod] string Login(string email, string password) { var a = Account.Where(x => x.Email == email).FirstOrDefault(); if (a == null) { Security.VerifyPassword(password); return ""; } if (a.PasswordHash == null) { return ""; } if (Security.VerifyPassword(password, a.PasswordHash)) { return Security.IssueJwt(a.Id, a.Email); } return ""; } app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, };
model/onboarding.osy78 lines
// ONBOARDING — an invitation desk. An admin invites an address; the invitee accepts, or the invitation chases them, // and eventually dies. The role tier and the auth methods live in `identity.osy`, beside every other demo's. enum InviteStatus { Pending, Active, Revoked, Expired } [Principal] entity Account { [Required, MaxLength(100)] string Name; [Required, MaxLength(200), Unique] string Email; [MaxLength(200)] string? PasswordHash; security { allow read when IsAuthenticated; allow read, create, update when IsAuthenticator; allow create when IsAdmin; allow update when IsAdmin; deny read PasswordHash when !IsAuthenticator; // nobody but the auth flow ever sees the hash } } entity Invitation { [Required, MaxLength(200)] string Email; InviteStatus Status; [Required] Account InvitedBy; [MaxLength(500)] string? AcceptLink; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } workflow Onboarding { Tracks = Invitation.Status; Autostart = true; Initial = Pending; [Authorize(u => u.Email == this.Item.Email)] event Accept(string? passwordHash); [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Admin))] event Revoke(); on Revoke { goto Revoked; } state Pending { subscribe Accept() as Acceptance { Finished { Within = TimeSpan.FromDays(7); Remind Chase(After = TimeSpan.FromDays(3), ThenEvery = TimeSpan.FromDays(2)) { Nudge(this.Item); } Unfinished { goto Expired; } } } enter { this.Item.AcceptLink = Acceptance.CallbackUrl(); Log.Information("invitation for {Email} — accept link {Link}", this.Item.Email, this.Item.AcceptLink); } on Acceptance(string? passwordHash) { var existing = Account.Where(a => a.Email == this.Item.Email).FirstOrDefault(); if (existing == null) { if (passwordHash == null) { throw new ValidationException("accepting an invitation for an address with no account must set a password"); } var minted = new Account { Name = this.Item.Email, Email = this.Item.Email, PasswordHash = passwordHash }; new RoleGrant { Grantee = minted, Level = AppRole.Member }; } this.Item.AcceptLink = null; goto Active; } } terminal success Active { } terminal cancel Revoked { Message = "invitation revoked"; } terminal error Expired { Message = "invitation expired"; } } void Nudge(Invitation i) { Log.Information("invitation reminder — {Email} has not accepted yet", i.Email); }
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a // byte-identical `model/theme.osy`, and that test is what keeps them identical. // // ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point. // The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`, // `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own // `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment // one of them reached for a kit control, that control painted itself indigo next to the demo's // blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that // existed only in the theme file. // // So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name // the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it // does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything. // // Excluded by design — these five own their look and must NOT adopt this file: arcade, ember, // motion, gestures (each demonstrates a visual world) and Apps/recall-osy. theme Demo { Colors { // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a // considered app rather than an unstyled one, and the two should be distinguishable on sight. // It is the one saturated colour on the page; everything else is warm neutral, which is what // keeps "distinct" from turning into "loud". Primary = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6")); OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F"); Bg = Modes.Of(light: "#FAF9F7", dark: "#101317"); // the page — a touch warm, so cards read as lifted Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22"); // a card, a row, a lane Muted = Modes.Of(light: "#EFEEEA", dark: "#1E242B"); // an inline notice, a disabled field Border = Modes.Of(light: "#E4E2DC", dark: "#28303A"); OnBg = Modes.Of(light: "#14181D", dark: "#E7EBF0"); OnSurface = Modes.Of(light: "#14181D", dark: "#E7EBF0"); TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3"); // labels, timestamps, captions TextMuted = Modes.Of(light: "#7C848E", dark: "#7E8894"); // the quietest text on the page Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A")); Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343")); Danger = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A")); } // Names the kit does not have, so these are extensions rather than second spellings. Radius { Control = "8px"; Card = "12px"; } // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans` // shadows the kit's own token — so every kit control picks it up with no page edit at all. // Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font // add`), because a webfont a page merely NAMES is a webfont that silently falls back. // Only the faces the app SHIPS are named here — Mono is left at the kit default because no // demo renders code, and naming an unshipped family is a silent fallback. // Geist for the UI: a neutral grotesque with real character at small sizes, which is where a // dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page // title. The pairing is the whole look — one voice for reading, one for announcing. Font { Sans = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif"; Serif = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif"; } FontSize { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; } FontWeight { Regular = 400; Medium = 500; Semibold = 600; } // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. // ── APP EXTENSIONS ───────────────────────────────────────────────────────────────────────────── // Everything ABOVE this line is the shared theme, held byte-identical across the demos by // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may // NOT add a second spelling of one the kit already has; that is the defect this file replaced. }
model/pages/accept.osy59 lines
// THE LANDING PAGE — where the emailed link actually goes, and where an invitee with no account gets one. using Osyrin.Ui; [Page("/accept/{token}")] [Render(CSR)] [Title("Accept your invitation")] [AllowAnonymous] // …the entire point: whoever opens this has no account and cannot sign in component AcceptInvite(string token) { string password = ""; string error = ""; bool done = false; action Finish() { error = ""; if (password.Length < 8) { error = "Choose a password of at least 8 characters."; return; } error = AcceptInvitation(token, password); if (error == "") { done = true; } } action GoSignIn() { Navigation.Go("/"); } render { Stack(gap: 0, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) { Row(justify: Justify.Center, align: Align.Center, grow: 1) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 6, borderW: 1, border: Colors.Border, w: "100%", maxW: "440px") { if (done) { Stack(gap: 3) { Text("You're in.", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold); Text("Your account is ready and your password is set. Sign in to get started.", fontSize: FontSize.Body, color: Colors.TextSecondary); Button("Go to sign in", onPress: GoSignIn); } } else { Stack(gap: 4) { Stack(gap: 2) { Text("Accept your invitation", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold); Text("Choose a password to finish setting up your account.", fontSize: FontSize.Body, color: Colors.TextSecondary); } // `Field` IS the label + the input + the error, and it ties them together: the message is // announced as the input's own, not as unrelated red text somewhere below it. Hand-rolling the // three parts loses that association and leaves COLOUR as the only signal a field is invalid. Field("Password", value: password, placeholder: "at least 8 characters", type: "password", error: error); Button("Accept and set my password", onPress: Finish, tone: Tone.Primary); } } } } } } }
model/pages/board.osy128 lines
// THE INVITE DESK — the screen an admin runs an invitation flow from, and the screen an invitee accepts on. using Osyrin.Ui; [Page("/")] [Render(CSR)] [Title("Invitations")] component Board() { live var invitations = Invitation.Include(i => i.InvitedBy).OrderByDescending(i => i.CreatedAt).ToList(); DeskRow[] rows; on mount { rows = Desk(); } var me = Session.CurrentUser; string invitee = "dana@corp.test"; action Accept(Invitation i) { AcceptInvite(i); rows = Desk(); } action Revoke(Invitation i) { RevokeInvite(i); rows = Desk(); } action Invite() { InviteAddress(invitee); rows = Desk(); } action SignOut() { Session.SignOut(); } render { Stack(gap: 0, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) { Row(justify: Justify.Center, w: "100%", bg: Colors.Surface, borderW: 1, border: Colors.Border) { Row(align: Align.Center, justify: Justify.SpaceBetween, w: "100%", maxW: "820px", px: 5, h: "60px") { Text("Invitations", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Row(align: Align.Center, gap: 3) { Text(me.Email, fontSize: FontSize.Caption, color: Colors.TextSecondary); if (IsAdmin) { Text("Admin", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); } else { Text("Member", fontSize: FontSize.Caption, color: Colors.TextSecondary); } Pressable(onClick: SignOut) { Text("Sign out", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") { Stack(gap: 5, w: "100%", maxW: "820px", p: 5) { Stack(gap: 2) { Text("Outstanding invitations", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold); Text("An invitation chases itself and expires on its own — a reminder and a deadline on the run, not a " + "sweep and not a column.", fontSize: FontSize.Caption, color: Colors.TextSecondary); foreach (var i in invitations) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Row(justify: Justify.SpaceBetween, align: Align.Center, gap: 4) { Stack(gap: 1) { Text(i.Email, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold); Text("invited by " + i.InvitedBy.Name, fontSize: FontSize.Caption, color: Colors.TextSecondary); } Row(gap: 3, align: Align.Center) { if (i.Status == InviteStatus.Pending) { Text("Pending", fontSize: FontSize.Caption, color: Colors.TextSecondary); } else if (i.Status == InviteStatus.Active) { Text("Accepted", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); } else { Text(i.Status, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Danger); } if (i.Status == InviteStatus.Pending) { if (i.Email == me.Email) { Button("Accept", onPress: () => Accept(i), tone: Tone.Primary); } if (IsAdmin) { Button("Revoke", onPress: () => Revoke(i), tone: Tone.Danger); } if (i.Email != me.Email && !IsAdmin) { Text("not yours", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } foreach (var r in rows) { if (r.InvitationId == i.Id) { Row(gap: 4, align: Align.Center, wrap: Wrapping.Wrap) { if (r.HasPromise) { Text("expires " + r.BreachesAt.ToString("D"), fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (r.Nudges == 1) { Text("chased once", fontSize: FontSize.Caption, color: Colors.TextSecondary); } else if (r.Nudges > 1) { Text("chased " + r.Nudges + " times", fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (r.EverBreached) { Text("deadline missed", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Danger); } } } } if (i.Email == me.Email && i.AcceptLink != null) { Stack(gap: 1, bg: Colors.Bg, rounded: Radius.Card, p: 3, borderW: 1, border: Colors.Border) { Text("Your accept link — POST to it, no account needed", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text(i.AcceptLink, fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Text("Invite an address", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Text("The address does not need an account. The link mints one when it is answered — signing up " + "afterwards sets its password rather than making a second account.", fontSize: FontSize.Caption, color: Colors.TextSecondary); Row(gap: 3, align: Align.End) { Stack(gap: 1, grow: 1) { Text("Email", fontSize: FontSize.Caption, color: Colors.TextSecondary); Input(value: invitee, label: "Email", placeholder: "someone@corp.test"); } Button("Invite", onPress: Invite, tone: Tone.Primary); } } } } } } } }
model/pages/login.osy68 lines
// Sign-in — AND SIGN-UP, which in this demo is part of the flow rather than scaffolding: `Accept` is authorized // over a principal, so an invitee has to become an account before they can accept their own invitation. using Osyrin.Ui; [Page("/login")] [AllowAnonymous] [Render(CSR)] [Title("Invitations — sign in")] component LoginPage() { string email = "ada@corp.test"; string password = "demo1234"; string problem = ""; action SignIn() { var ticket = Login(email, password); if (ticket == "") { problem = "That email and password don't match anyone."; } else { Session.SignIn(ticket); } } action Register() { var ticket = Signup(email, password); if (ticket == "") { problem = "Couldn't create that account — it may already exist."; } else { Session.SignIn(ticket); } } render { Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) { Stack(gap: 4, w: "100%", maxW: "420px") { Stack(gap: 1) { Text("Invitations", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em"); Text("One rule is a role, the other is you. They look the same and are not.", fontSize: FontSize.Body, color: Colors.TextSecondary); } Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Stack(gap: 1) { Text("Email", labelFor: emailBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Input(value: email, id: emailBox, placeholder: "you@acme.test"); } Stack(gap: 1) { Text("Password", labelFor: passwordBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Input(value: password, id: passwordBox, type: "password"); } if (problem != "") { Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) { Text(problem, fontSize: FontSize.Caption, color: Colors.Danger); } } Button("Sign in", onPress: SignIn, tone: Tone.Primary); Button("Create this account", onPress: Register, tone: Tone.Primary); } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 2) { Text("Seeded people — password demo1234", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Text("ada@corp.test — the Admin GRANT: may revoke ANY invite", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("alice@corp.test — a Member: may accept HER invite, and no other", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("mallory@corp.test — a Member with no invite: may accept nothing", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } }