Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 57 additions & 30 deletions src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved

readonly TimeProvider _time = time;

Expand All @@ -122,6 +123,10 @@ public sealed class WizardTenantProvisioner(
public Func<string, string, CancellationToken, Task<bool>>? ConfirmCreate { get; set; }
public Action<int, int>? PollProgress { get; set; }

/// Asks for the next invite code, given where to request one and why the last code was refused
/// (null on the first ask). Null or blank means the user gave up.
public Func<string?, string?, CancellationToken, Task<string?>>? 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<string, CancellationToken, Task<AvailabilityResponse?>>? CheckSlug { get; private set; }
Expand Down Expand Up @@ -171,28 +176,49 @@ async Task<ProvisionOffer> 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;
string? requestUrl = 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;
requestUrl = outcome.Body.RequestUrl is { Length: > 0 } fresh ? fresh : requestUrl;
var codeRejected = code is not null || reason != "invite_required";

// Three rejected codes end the offer; a 403 to a supplied code counts as one.
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}");
}
}
}

Expand Down Expand Up @@ -293,15 +319,16 @@ 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;
return LinkPolicy.IsOpenable(requestUrl) ? $"{why} Request an invite at {requestUrl}." : why;
}

static string Reason400(string? reason) => reason switch {
Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.App/ViewModels/Onboarding/SignInPhase.cs
Original file line number Diff line number Diff line change
@@ -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 }
64 changes: 57 additions & 7 deletions src/Capacitor.App/ViewModels/Onboarding/SignInStepViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public async Task<T> AskAsync(Action<Action> post, Action show, Action hide, Can
readonly UiQuestion<string?> _orgName = new();
readonly UiQuestion<string?> _slug = new();
readonly UiQuestion<bool> _confirm = new();
readonly UiQuestion<string?> _inviteCode = new();

AuthAttempt? _attempt;
ConnectIntent? _running;
Expand All @@ -78,6 +79,10 @@ public async Task<T> AskAsync(Action<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;
Expand Down Expand Up @@ -169,6 +174,14 @@ 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 = "";
// The URL comes from whichever signup server the client was pointed at; only a web URL is offered.
InviteRequestUrl = LinkPolicy.IsOpenable(requestUrl) ? requestUrl : null;
InviteCodeError = error;
InviteCodePromptVisible = true;
}, () => InviteCodePromptVisible = false, ct);
provisioner.PollProgress = (attempt, max) =>
_post(() => ProvisioningProgress = "Your workspace is still being prepared…");

Expand Down Expand Up @@ -216,6 +229,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(() => LinkPolicy.Open(_urlOpener, InviteRequestUrl));

AcknowledgeQuarantineCommand = ReactiveCommand.CreateFromTask(AcknowledgeQuarantineAsync);
}
Expand All @@ -224,7 +241,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
Expand All @@ -236,6 +254,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",
};
Expand All @@ -251,6 +270,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,
};
Expand Down Expand Up @@ -283,6 +303,9 @@ public SignInStepViewModel(
public ReactiveCommand<Unit, Unit> CancelSlugCommand { get; }
public ReactiveCommand<Unit, Unit> ConfirmCreateCommand { get; }
public ReactiveCommand<Unit, Unit> DeclineCreateCommand { get; }
public ReactiveCommand<Unit, Unit> SubmitInviteCodeCommand { get; }
public ReactiveCommand<Unit, Unit> CancelInviteCodeCommand { get; }
public ReactiveCommand<Unit, Unit> OpenInviteRequestUrlCommand { get; }
public ReactiveCommand<Unit, Unit> AcknowledgeQuarantineCommand { get; }
public ReactiveCommand<Unit, Unit> ContinueWithWorkAccountCommand { get; }
public ReactiveCommand<Unit, Unit> UseCodeCommand { get; }
Expand Down Expand Up @@ -438,6 +461,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 {
Expand Down Expand Up @@ -767,6 +815,7 @@ void ClearTransient() {
BrowserUrl = null;
WaitingText = null;
ProvisioningProgress = null;
InviteCode = "";
}

void CommitTenant(DiscoveredTenant? tenant) {
Expand All @@ -778,12 +827,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) {
Expand Down
26 changes: 26 additions & 0 deletions src/Capacitor.App/Views/Onboarding/SignInPaneView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,32 @@
</StackPanel>
</Border>

<Border Classes="frCard" IsVisible="{Binding InviteCodePromptVisible}">
<StackPanel Spacing="16">
<StackPanel Spacing="8">
<TextBlock Classes="frMicro" Text="INVITE CODE" />
<TextBox x:Name="InviteCodeBox" Classes="frField" Width="160" HorizontalAlignment="Left"
PlaceholderText="ABCD" FontFamily="{StaticResource FrMonoFont}"
AutomationProperties.Name="Invite code"
Text="{Binding InviteCode, Mode=TwoWay}" />
<TextBlock x:Name="InviteCodeErrorText" Classes="frError" Text="{Binding InviteCodeError}"
IsVisible="{Binding InviteCodeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Spacing="4"
IsVisible="{Binding InviteRequestUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Classes="frDetail" Text="Don't have one? Request an invite:" />
<Button x:Name="InviteRequestUrlButton" Classes="frLink" HorizontalAlignment="Left"
Content="{Binding InviteRequestUrl}" Command="{Binding OpenInviteRequestUrlCommand}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="12">
<Button x:Name="SubmitInviteCodeButton" Classes="frPrimary" Content="Continue"
Command="{Binding SubmitInviteCodeCommand}" />
<Button x:Name="CancelInviteCodeButton" Classes="frSecondary" Content="Cancel"
Command="{Binding CancelInviteCodeCommand}" />
</StackPanel>
</StackPanel>
</Border>

<Border Classes="frCard" IsVisible="{Binding ProvisioningVisible}">
<StackPanel Spacing="14">
<StackPanel Orientation="Horizontal" Spacing="14">
Expand Down
37 changes: 36 additions & 1 deletion src/Capacitor.App/Views/Onboarding/SignInStepView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@

<TextBlock x:Name="ProvisioningText" Text="{Binding ProvisioningProgress}"
TextWrapping="Wrap" Foreground="{StaticResource KcapMutedBrush}" FontSize="12.5"
IsVisible="{Binding ProvisioningProgress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
IsVisible="{Binding ProvisioningVisible}" />
</StackPanel>

<StackPanel Spacing="12">
Expand Down Expand Up @@ -245,6 +245,41 @@
</StackPanel>
</StackPanel>

<StackPanel Spacing="8" IsVisible="{Binding InviteCodePromptVisible}">
<TextBlock Text="Capacitor is invite-only right now. Enter your invite code to create the workspace."
TextWrapping="Wrap" Foreground="{StaticResource KcapTextBrush}" FontSize="13.5" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox x:Name="InviteCodeBox" Classes="kcapField" Width="120"
PlaceholderText="ABCD" AutomationProperties.Name="Invite code"
Text="{Binding InviteCode, Mode=TwoWay}" />
<Button x:Name="SubmitInviteCodeButton" Classes="kcapPrimary" Content="Continue"
Command="{Binding SubmitInviteCodeCommand}"
Background="{StaticResource KcapPrimaryBrush}" Foreground="{StaticResource KcapOnPrimaryBrush}"
FontWeight="SemiBold" CornerRadius="8" Padding="14,8" />
<Button x:Name="CancelInviteCodeButton" Classes="kcapChip" Content="Cancel"
Command="{Binding CancelInviteCodeCommand}"
Background="{StaticResource KcapSurfaceRaisedBrush}"
BorderBrush="{StaticResource KcapBorderBrush}"
Foreground="{StaticResource KcapTextBrush}"
CornerRadius="8" Padding="14,8" />
</StackPanel>
<TextBlock x:Name="InviteCodeErrorText" Text="{Binding InviteCodeError}" TextWrapping="Wrap"
Foreground="{StaticResource KcapDangerBrush}" FontSize="12.5"
IsVisible="{Binding InviteCodeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<StackPanel Orientation="Horizontal" Spacing="8"
IsVisible="{Binding InviteRequestUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Don't have one?" VerticalAlignment="Center"
Foreground="{StaticResource KcapMutedBrush}" FontSize="12.5" />
<Button x:Name="InviteRequestUrlButton" Classes="kcapChip"
Content="{Binding InviteRequestUrl}"
Command="{Binding OpenInviteRequestUrlCommand}"
Background="{StaticResource KcapSurfaceRaisedBrush}"
BorderBrush="{StaticResource KcapBorderBrush}"
Foreground="{StaticResource KcapTextBrush}"
CornerRadius="8" Padding="12,7" FontSize="12" />
</StackPanel>
</StackPanel>

<StackPanel Spacing="10"
IsVisible="{Binding QuarantineNotice, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock x:Name="QuarantineNoticeText" Text="{Binding QuarantineNotice}"
Expand Down
Loading
Loading