a simpler UI
This commit is contained in:
parent
84160f0759
commit
2263311e1b
4 changed files with 122 additions and 5 deletions
|
|
@ -101,4 +101,80 @@ public class LoginPageViewModelTests
|
||||||
var vm = new LoginPageViewModel(settings);
|
var vm = new LoginPageViewModel(settings);
|
||||||
Assert.False(vm.ConfigMissing);
|
Assert.False(vm.ConfigMissing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")]
|
||||||
|
[InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")]
|
||||||
|
[InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")]
|
||||||
|
public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected)
|
||||||
|
{
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings { Authority = authority }
|
||||||
|
};
|
||||||
|
var vm = new LoginPageViewModel(settings);
|
||||||
|
Assert.Equal(expected, vm.DiscoveryUrl);
|
||||||
|
// ExternalUrl is the slash-normalised form of Authority.
|
||||||
|
Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscoveryUrl_is_empty_when_authority_is_unset()
|
||||||
|
{
|
||||||
|
var vm = new LoginPageViewModel(new PostIt.Settings());
|
||||||
|
Assert.Equal(string.Empty, vm.DiscoveryUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_failure_message_includes_discovery_url()
|
||||||
|
{
|
||||||
|
// Arrange: settings point at an unreachable authority; the test
|
||||||
|
// browser throws synchronously to guarantee the catch branch runs.
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://does-not-exist.invalid/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = "http://127.0.0.1:7890/",
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
// Assert: the surfaced error mentions the canonical discovery URL,
|
||||||
|
// so it can be copy-pasted into a browser to diagnose reachability.
|
||||||
|
Assert.NotNull(vm.StatusMessage);
|
||||||
|
Assert.StartsWith("Error:", vm.StatusMessage);
|
||||||
|
Assert.Contains(
|
||||||
|
"https://does-not-exist.invalid/.well-known/openid-configuration",
|
||||||
|
vm.StatusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_reports_discovery_url_when_no_browser_available()
|
||||||
|
{
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://yavsc.example.com/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = "http://127.0.0.1:7890/",
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings, () => null);
|
||||||
|
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
"https://yavsc.example.com/.well-known/openid-configuration",
|
||||||
|
vm.StatusMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -59,11 +59,20 @@ public partial class Settings : ObservableObject
|
||||||
/// (no client secret). The browser implementation should be supplied
|
/// (no client secret). The browser implementation should be supplied
|
||||||
/// per-platform by the caller.
|
/// per-platform by the caller.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="AuthenticationSettings.Authority"/> is normalised by
|
||||||
|
/// trimming any trailing slash before being handed to <c>OidcClient</c>.
|
||||||
|
/// <c>OidcClient</c> derives the discovery URL from
|
||||||
|
/// <c>Authority + "/.well-known/openid-configuration"</c>; leaving a
|
||||||
|
/// trailing slash in place would produce a double-slash URL that some
|
||||||
|
/// servers reject with 404.
|
||||||
|
/// </remarks>
|
||||||
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
|
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
|
||||||
{
|
{
|
||||||
|
var authority = Authentication.Authority?.TrimEnd('/') ?? string.Empty;
|
||||||
var options = new OidcClientOptions
|
var options = new OidcClientOptions
|
||||||
{
|
{
|
||||||
Authority = Authentication.Authority,
|
Authority = authority,
|
||||||
ClientId = Authentication.ClientId,
|
ClientId = Authentication.ClientId,
|
||||||
RedirectUri = RedirectUri,
|
RedirectUri = RedirectUri,
|
||||||
Scope = string.Join(' ', this.Scopes),
|
Scope = string.Join(' ', this.Scopes),
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,23 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl);
|
public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl);
|
||||||
public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl);
|
public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical <see cref="Settings.Authentication"/> authority with any trailing
|
||||||
|
/// slash removed. Used as the base for both the OIDC discovery URL and the
|
||||||
|
/// human-facing Account URLs (Register / Forgot password). Empty when the
|
||||||
|
/// authority is not configured.
|
||||||
|
/// </summary>
|
||||||
|
public string ExternalUrl => BuildExternalUrl(string.Empty);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OIDC discovery URL the client actually calls during login:
|
||||||
|
/// <c>ExternalUrl + "/.well-known/openid-configuration"</c>. Surfaced in
|
||||||
|
/// <see cref="StatusMessage"/> on failure so the operator can copy it
|
||||||
|
/// verbatim and verify reachability from a browser.
|
||||||
|
/// </summary>
|
||||||
|
public string DiscoveryUrl =>
|
||||||
|
string.IsNullOrEmpty(ExternalUrl) ? string.Empty : ExternalUrl + "/.well-known/openid-configuration";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when the settings file is missing or <c>Authentication.Authority</c>
|
/// True when the settings file is missing or <c>Authentication.Authority</c>
|
||||||
/// is empty. The LoginPage surfaces a banner in that case and disables
|
/// is empty. The LoginPage surfaces a banner in that case and disables
|
||||||
|
|
@ -112,12 +129,21 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
? Platform.DefaultRedirectUri
|
? Platform.DefaultRedirectUri
|
||||||
: Settings.RedirectUri;
|
: Settings.RedirectUri;
|
||||||
|
|
||||||
|
// Surface the discovery URL the client is about to call, so a
|
||||||
|
// failure (DNS, TLS, 404) can be diagnosed by pasting the URL
|
||||||
|
// straight into a browser. OidcClient computes the discovery
|
||||||
|
// URL as `Authority + /.well-known/openid-configuration`; we
|
||||||
|
// normalise the trailing slash here so the printed URL is
|
||||||
|
// exactly what IdentityModel will fetch.
|
||||||
|
if (!string.IsNullOrEmpty(DiscoveryUrl))
|
||||||
|
StatusMessage = $"Discovering {DiscoveryUrl}";
|
||||||
|
|
||||||
var browser = BrowserFactoryOverride is not null
|
var browser = BrowserFactoryOverride is not null
|
||||||
? BrowserFactoryOverride.Invoke()
|
? BrowserFactoryOverride.Invoke()
|
||||||
: Platform.CreateBrowser?.Invoke();
|
: Platform.CreateBrowser?.Invoke();
|
||||||
if (browser is null)
|
if (browser is null)
|
||||||
{
|
{
|
||||||
StatusMessage = "No browser is available on this platform.";
|
StatusMessage = $"No browser is available on this platform. (discovery: {DiscoveryUrl})";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +152,7 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
|
|
||||||
if (loginResult.IsError)
|
if (loginResult.IsError)
|
||||||
{
|
{
|
||||||
StatusMessage = loginResult.Error;
|
StatusMessage = $"{loginResult.Error} (discovery: {DiscoveryUrl})";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,7 +170,8 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
this.IsBusy = false;
|
this.IsBusy = false;
|
||||||
StatusMessage = "Error: "+ex.Message;
|
var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty;
|
||||||
|
StatusMessage = $"Error: {ex.Message}{suffix}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,12 @@
|
||||||
IsEnabled="{Binding HasForgotPasswordUrl}"
|
IsEnabled="{Binding HasForgotPasswordUrl}"
|
||||||
Click="OnForgotPasswordClick"/>
|
Click="OnForgotPasswordClick"/>
|
||||||
|
|
||||||
<TextBlock Name="StatusText" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
<TextBox Name="StatusText"
|
||||||
|
Text="{Binding StatusMessage}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsReadOnly="True"
|
||||||
|
BorderThickness="0"
|
||||||
|
Background="Transparent"/>
|
||||||
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
|
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue