wf-signup-invite
An invite-and-accept signup — a small reference for Osysharp.Invitations (D-DEMOSTOKITS1: what this demo proved is the kit now; the demo shows how an app invokes it). An admin invites an address with a role; the invitee answers the mailed link — or, if they already have an account, signs in and accepts on the board.
4 source files1 test file

Get it
$ osy init wf-signup-invite $ osy launch
The app
app.osy17 lines
// An invite-and-accept signup — a small reference for Osysharp.Invitations (D-DEMOSTOKITS1: what this demo proved is the // kit now; the demo shows how an app invokes it). An admin invites an address with a role; the invitee answers the // mailed link — or, if they already have an account, signs in and accepts on the board. app WfSignupInvite { 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"; tests "tests/**/*.test.osy"; }
model/identity.osy23 lines
// Everything this app writes about its people: its roles, who may invite, and how its roles read. Signing in is // Osysharp.UserAccounts'; inviting is Osysharp.Invitations' — this file only answers their questions. using Osysharp.Identity; using Osysharp.UserAccounts; using Osysharp.Invitations; [Role] enum AppRole { Authenticator, Member, Admin } policy IsAdmin => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Admin); /// The Invitations kit's question: who invites people (and revokes and resends invitations). policy MayInvite => IsAdmin; /// The Organizations kit's question (it comes with Invitations): who looks after organisations — nobody here uses any. policy ManagesOrganizations => IsAdmin; // Closed to sign-up: an invitation is the way in. Whoever sets the app up (`osy user add`) is its admin. app.Accounts = new AccountsSetup { AppName = "Invitations", SignupOpen = false, FirstAccountRole = AppRole.Admin }; // The roles an invitation may carry, by the names people read. app.Roles = new RolesSetup { Offered = [ new RoleChoice { Role = AppRole.Member, Label = "Member", Description = "Works here." }, new RoleChoice { Role = AppRole.Admin, Label = "Admin", Description = "Invites people, and runs the place." }, ] };
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a // byte-identical copy as its theme (`model/look/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 `Osysharp.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: "#636B75", dark: "#8A94A0"); // the quietest text on the page — still WCAG AA (4.5:1) on Bg, Surface and Muted: #7C848E read 3.3–3.8:1, and the kit draws every field label in it (the UX sweep, test:visual) 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/board.osy57 lines
// THE BOARD — an admin invites and watches who has not answered; anybody signed in sees the invitations waiting for // THEM and accepts. The mailed link goes to the kit's own `/invite/{token}` page. using Osysharp.Identity; using Osysharp.UserAccounts; using Osysharp.Invitations; [Route("/")] [Render(CSR)] [Title("Invitations")] component Board() { live var me = Session.CurrentUser; List<InvitationView> mine = []; string accepted = ""; string problem = ""; on mount { mine = MyInvitationViews(); } action Accept(Guid id) { problem = ""; try { accepted = AcceptInvitationById(id); mine = MyInvitationViews(); } catch (Exception ex) { problem = ex.Message; } } 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, borderBW: 1, border: Colors.Border) { Row(align: Align.Center, justify: Justify.Between, w: "100%", maxW: "820px", px: 5, h: "60px") { Text("Invitations", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Row(align: Align.Center, gap: 3) { Text(me == null ? "" : me.Email, fontSize: FontSize.Caption, color: Colors.TextSecondary); Button("Sign out", onPress: SignOut, tone: Tone.Ghost, size: Size.Sm); } } } Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") { Stack(gap: 5, w: "100%", maxW: "820px", p: 5) { if (accepted != "") { Alert($"Welcome to {accepted}.", tone: Tone.Success); } if (problem != "") { Alert(problem, tone: Tone.Danger); } if (mine.Count > 0) { Card("Waiting for you") { foreach (var i in mine) { Row(gap: 3, align: Align.Center, label: i.Into) { Text($"{i.Into}, from {i.From}" + (i.RoleName != "" ? $", as {i.RoleName}" : ""), grow: 1); Button("Accept", onPress: () => Accept(i.Id), tone: Tone.Primary); } } } } Stack(canSee: MayInvite, gap: 5) { Card("Invite someone", subtitle: "They get a link by mail, and join with the role you choose.") { InviteForm(canSend: MayInvite); } Card("Not answered yet") { InvitationList(canChange: MayInvite); } } } } } } }