From 44a347ec30096ef562a3c0b6fea0d3c8725f28f8 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:39:50 -0400 Subject: [PATCH 1/4] Ask for an invite code in the desktop workspace wizard A 403 invite_required answering a code just supplied counts toward the three-code cap, so a server that keeps asking cannot loop the prompt. Co-Authored-By: Claude Opus 5.5 --- .../Services/Onboarding/WizardAuthBridges.cs | 83 ++++++---- .../ViewModels/Onboarding/SignInPhase.cs | 2 +- .../Onboarding/SignInStepViewModel.cs | 62 +++++++- .../Views/Onboarding/SignInPaneView.axaml | 26 ++++ .../Views/Onboarding/SignInStepView.axaml | 37 ++++- .../SignInStepViewModelTests.cs | 147 ++++++++++++++++++ .../WizardAuthBridgesTests.cs | 122 ++++++++++++++- 7 files changed, 440 insertions(+), 39 deletions(-) diff --git a/src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs b/src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs index 471ae14637..84b3d86303 100644 --- a/src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs +++ b/src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs @@ -112,6 +112,7 @@ public sealed class WizardTenantProvisioner( TimeProvider time) : ITenantProvisioner { internal const int PollIntervalMs = 4000; internal const int MaxPolls = 150; // ~10 minutes (server budget is 15) + internal const int MaxInviteCodeAttempts = 3; readonly TimeProvider _time = time; @@ -122,6 +123,9 @@ public sealed class WizardTenantProvisioner( public Func>? ConfirmCreate { get; set; } public Action? PollProgress { get; set; } + /// Asked with where to request an invite and the complaint about the last code, if any. + public Func>? PromptInviteCode { get; set; } + /// Asks the signup service whether a slug is free, with the token the offer is running under. /// Set only while an offer runs, so a form can check its slug as the user types it. public Func>? CheckSlug { get; private set; } @@ -171,28 +175,48 @@ async Task OfferCreateCoreAsync(WorkOSTokenSource tokens, Cancel telemetry.Funnel.WorkspaceRequested(); - var outcome = await client.ProvisionAsync( - baseUrl, await tokens.GetAsync(ct), orgName, slug, WorkspaceTier.Free, telemetry.Join.Current, inviteCode: null, ct); - - switch (outcome.StatusCode) { - case 200 when outcome.Body?.WorkosOrgId is { Length: > 0 } orgId: - telemetry.Funnel.WorkspaceProvisioned(); - - return ProvisionOffer.Created(new ProvisionedTenant(orgId, slug, orgName, outcome.Body.Url ?? origin)); - case 202 or 200: - return await PollAsync(tokens, slug, orgName, origin, ct); - case 403 when outcome.Body?.Reason == "invite_required": - case 400 when outcome.Body?.Reason == "invalid_code": - case 409 when outcome.Body?.Reason == "code_exhausted": - return Failed(InviteRefusal(outcome.Body.Reason, outcome.Body.RequestUrl), outcome.Body.Reason); - case 400: - return Failed(Reason400(outcome.Body?.Reason), outcome.Body?.Reason ?? "invalid_request"); - case 409: - return Failed(Reason409(outcome.Body?.Reason, slug), outcome.Body?.Reason ?? "conflict"); - case 0: - return Failed("Couldn't reach the provisioning service. Check your connection and try again.", "unreachable"); - default: - return Failed($"Provisioning failed (HTTP {outcome.StatusCode}). Try again later.", $"http_{outcome.StatusCode}"); + string? code = null; + var wrongCodes = 0; + + while (true) { + var outcome = await client.ProvisionAsync( + baseUrl, await tokens.GetAsync(ct), orgName, slug, WorkspaceTier.Free, telemetry.Join.Current, code, ct); + + switch (outcome.StatusCode) { + case 200 when outcome.Body?.WorkosOrgId is { Length: > 0 } orgId: + telemetry.Funnel.WorkspaceProvisioned(); + + return ProvisionOffer.Created(new ProvisionedTenant(orgId, slug, orgName, outcome.Body.Url ?? origin)); + case 202 or 200: + return await PollAsync(tokens, slug, orgName, origin, ct); + case 403 when outcome.Body?.Reason == "invite_required": + case 400 when outcome.Body?.Reason == "invalid_code": + case 409 when outcome.Body?.Reason == "code_exhausted": { + var reason = outcome.Body.Reason; + var requestUrl = outcome.Body.RequestUrl; + var codeRejected = code is not null || reason != "invite_required"; + + // A server that keeps asking for a code it was just given must not re-prompt forever. + if (codeRejected && ++wrongCodes >= MaxInviteCodeAttempts) + return Failed(InviteRefusal(reason, requestUrl, codeRejected), reason); + + code = PromptInviteCode is null + ? null + : (await PromptInviteCode(requestUrl, codeRejected ? CodeComplaint(reason) : null, ct))?.Trim(); + + if (string.IsNullOrEmpty(code)) return Failed(InviteRefusal(reason, requestUrl, codeRejected), reason); + + continue; + } + case 400: + return Failed(Reason400(outcome.Body?.Reason), outcome.Body?.Reason ?? "invalid_request"); + case 409: + return Failed(Reason409(outcome.Body?.Reason, slug), outcome.Body?.Reason ?? "conflict"); + case 0: + return Failed("Couldn't reach the provisioning service. Check your connection and try again.", "unreachable"); + default: + return Failed($"Provisioning failed (HTTP {outcome.StatusCode}). Try again later.", $"http_{outcome.StatusCode}"); + } } } @@ -293,13 +317,14 @@ ProvisionOffer Failed(string message, string reason) { return ProvisionOffer.Failed; } - // The wizard has no invite-code entry, so a gated server can only be answered with where to ask. - static string InviteRefusal(string reason, string? requestUrl) { - var why = reason switch { - "invalid_code" => "That invite code isn't valid.", - "code_exhausted" => "That invite code has been used up.", - _ => "Capacitor is invite-only right now." - }; + static string CodeComplaint(string reason) => reason switch { + "invalid_code" => "That invite code isn't valid.", + "code_exhausted" => "That invite code has been used up.", + _ => "That invite code wasn't accepted." + }; + + static string InviteRefusal(string reason, string? requestUrl, bool codeRejected) { + var why = codeRejected ? CodeComplaint(reason) : "Capacitor is invite-only right now."; return requestUrl is { Length: > 0 } ? $"{why} Request an invite at {requestUrl}." : why; } diff --git a/src/Capacitor.App/ViewModels/Onboarding/SignInPhase.cs b/src/Capacitor.App/ViewModels/Onboarding/SignInPhase.cs index 633ac6b297..fb20b70991 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/SignInPhase.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/SignInPhase.cs @@ -1,4 +1,4 @@ namespace Capacitor.App.ViewModels.Onboarding; /// Where a sign-in stands, for the page's headline and which panel it shows. -public enum SignInPhase { Start, Waiting, PickWorkspace, NoWorkspace, CreateWorkspace, ConfirmCreate, Provisioning } +public enum SignInPhase { Start, Waiting, PickWorkspace, NoWorkspace, CreateWorkspace, ConfirmCreate, InviteCode, Provisioning } diff --git a/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs b/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs index ebae3e4225..ee19f5c714 100644 --- a/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs +++ b/src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs @@ -56,6 +56,7 @@ public async Task AskAsync(Action post, Action show, Action hide, Can readonly UiQuestion _orgName = new(); readonly UiQuestion _slug = new(); readonly UiQuestion _confirm = new(); + readonly UiQuestion _inviteCode = new(); AuthAttempt? _attempt; ConnectIntent? _running; @@ -78,6 +79,10 @@ public async Task AskAsync(Action post, Action show, Action hide, Can bool _orgNamePromptVisible; bool _slugPromptVisible; bool _confirmVisible; + bool _inviteCodePromptVisible; + string _inviteCodeText = ""; + string? _inviteCodeError; + string? _inviteRequestUrl; string _orgNameText = ""; string _slugText = ""; string? _slugError; @@ -169,6 +174,13 @@ public SignInStepViewModel( ConfirmText = $"Create {(string.IsNullOrWhiteSpace(OrgName) ? slug : OrgName.Trim())} at {Host(origin)}?"; ConfirmVisible = true; }, () => ConfirmVisible = false, ct); + provisioner.PromptInviteCode = (requestUrl, error, ct) => _inviteCode.AskAsync(_post, () => { + // A complaint keeps the refused code in the box so a typo can be fixed in place. + if (error is null) InviteCode = ""; + InviteRequestUrl = requestUrl; + InviteCodeError = error; + InviteCodePromptVisible = true; + }, () => InviteCodePromptVisible = false, ct); provisioner.PollProgress = (attempt, max) => _post(() => ProvisioningProgress = "Your workspace is still being prepared…"); @@ -216,6 +228,10 @@ public SignInStepViewModel( _confirm.Answer(true); }); DeclineCreateCommand = ReactiveCommand.Create(() => _confirm.Answer(false)); + SubmitInviteCodeCommand = ReactiveCommand.Create(() => _inviteCode.Answer(InviteCode), + this.WhenAnyValue(x => x.InviteCode, code => !string.IsNullOrWhiteSpace(code))); + CancelInviteCodeCommand = ReactiveCommand.Create(() => _inviteCode.Answer(null)); + OpenInviteRequestUrlCommand = ReactiveCommand.Create(() => Open(InviteRequestUrl)); AcknowledgeQuarantineCommand = ReactiveCommand.CreateFromTask(AcknowledgeQuarantineAsync); } @@ -224,7 +240,8 @@ public SignInStepViewModel( public bool Applicable => true; public SignInPhase Phase => - ConfirmVisible ? SignInPhase.ConfirmCreate + InviteCodePromptVisible ? SignInPhase.InviteCode + : ConfirmVisible ? SignInPhase.ConfirmCreate : OrgNamePromptVisible || SlugPromptVisible ? SignInPhase.CreateWorkspace : ModeChoiceVisible ? SignInPhase.NoWorkspace : TenantPickerVisible ? SignInPhase.PickWorkspace @@ -236,6 +253,7 @@ public SignInStepViewModel( SignInPhase.PickWorkspace => "Choose a workspace", SignInPhase.NoWorkspace => "No Capacitor workspace yet", SignInPhase.CreateWorkspace or SignInPhase.ConfirmCreate => "Create your workspace", + SignInPhase.InviteCode => "Enter your invite code", SignInPhase.Provisioning => "Setting up your workspace", _ => "Sign in to Capacitor", }; @@ -251,6 +269,7 @@ public SignInStepViewModel( "Your account isn't in a Capacitor workspace. Create one now, or point this machine at one you already have.", SignInPhase.CreateWorkspace or SignInPhase.ConfirmCreate => "Your team's sessions live here. You can invite people from it once it's ready.", + SignInPhase.InviteCode => "Capacitor is invite-only right now.", SignInPhase.Provisioning => "This usually takes about five minutes, sometimes longer. Keep this window open.", _ => null, }; @@ -283,6 +302,9 @@ public SignInStepViewModel( public ReactiveCommand CancelSlugCommand { get; } public ReactiveCommand ConfirmCreateCommand { get; } public ReactiveCommand DeclineCreateCommand { get; } + public ReactiveCommand SubmitInviteCodeCommand { get; } + public ReactiveCommand CancelInviteCodeCommand { get; } + public ReactiveCommand OpenInviteRequestUrlCommand { get; } public ReactiveCommand AcknowledgeQuarantineCommand { get; } public ReactiveCommand ContinueWithWorkAccountCommand { get; } public ReactiveCommand UseCodeCommand { get; } @@ -438,6 +460,31 @@ private set { } } + public bool InviteCodePromptVisible { + get => _inviteCodePromptVisible; + private set { + this.RaiseAndSetIfChanged(ref _inviteCodePromptVisible, value); + RestatePhase(); + } + } + + public string InviteCode { + get => _inviteCodeText; + set => this.RaiseAndSetIfChanged(ref _inviteCodeText, value); + } + + /// Why the last code was refused; null on the first ask. + public string? InviteCodeError { + get => _inviteCodeError; + private set => this.RaiseAndSetIfChanged(ref _inviteCodeError, value); + } + + /// Where the server says to ask for an invite, when it names one. + public string? InviteRequestUrl { + get => _inviteRequestUrl; + private set => this.RaiseAndSetIfChanged(ref _inviteRequestUrl, value); + } + public string OrgName { get => _orgNameText; set { @@ -778,12 +825,13 @@ void CommitTenant(DiscoveredTenant? tenant) { } void HidePrompts() { - _tenantChoiceMade = false; - TenantPickerVisible = false; - ModeChoiceVisible = false; - OrgNamePromptVisible = false; - SlugPromptVisible = false; - ConfirmVisible = false; + _tenantChoiceMade = false; + TenantPickerVisible = false; + ModeChoiceVisible = false; + OrgNamePromptVisible = false; + SlugPromptVisible = false; + ConfirmVisible = false; + InviteCodePromptVisible = false; } void SetStatus(string text, bool isError, string? detail = null) { diff --git a/src/Capacitor.App/Views/Onboarding/SignInPaneView.axaml b/src/Capacitor.App/Views/Onboarding/SignInPaneView.axaml index d97979e36e..bcbdd012e4 100644 --- a/src/Capacitor.App/Views/Onboarding/SignInPaneView.axaml +++ b/src/Capacitor.App/Views/Onboarding/SignInPaneView.axaml @@ -211,6 +211,32 @@ + + + + + + + + + +