diff --git a/src/Sovrant.Desktop/ViewModels/LoginViewModel.cs b/src/Sovrant.Desktop/ViewModels/LoginViewModel.cs index 29f46200..4bf369e7 100644 --- a/src/Sovrant.Desktop/ViewModels/LoginViewModel.cs +++ b/src/Sovrant.Desktop/ViewModels/LoginViewModel.cs @@ -17,8 +17,18 @@ public partial class LoginViewModel : ObservableObject [ObservableProperty] private string _email = string.Empty; [ObservableProperty] private string _password = string.Empty; [ObservableProperty] private string _errorMessage = string.Empty; + [ObservableProperty] private string _infoMessage = string.Empty; [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _busyLabel = string.Empty; [ObservableProperty] private bool _isRegistrationOpen; + [ObservableProperty] private bool _isFirstRun; + [ObservableProperty] private bool _isApprovalRequired; + + /// Registration section is only offered once an admin exists and registration is open. + public bool ShowRegistrationSection => !IsFirstRun && IsRegistrationOpen; + + partial void OnIsFirstRunChanged(bool value) => OnPropertyChanged(nameof(ShowRegistrationSection)); + partial void OnIsRegistrationOpenChanged(bool value) => OnPropertyChanged(nameof(ShowRegistrationSection)); public event Action? LoginSucceeded; // (userId, role, email) @@ -31,20 +41,31 @@ public LoginViewModel(IIdentityService identity, ITokenService tokens, ICredenti public async Task InitializeAsync() { + IsFirstRun = await _identity.IsFirstRunAsync().ConfigureAwait(true); IsRegistrationOpen = await _identity.IsRegistrationOpenAsync().ConfigureAwait(true); + IsApprovalRequired = !IsFirstRun && IsRegistrationOpen + && await _identity.IsApprovalRequiredAsync().ConfigureAwait(true); } - [RelayCommand] - private async Task LoginAsync() + private bool ValidateInput() { ErrorMessage = string.Empty; + InfoMessage = string.Empty; if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password)) { ErrorMessage = "Email and password are required."; - return; + return false; } + return true; + } + + [RelayCommand] + private async Task LoginAsync() + { + if (!ValidateInput()) return; IsBusy = true; + BusyLabel = "Signing you in…"; try { var result = await _identity.LoginAsync(Email, Password).ConfigureAwait(true); @@ -66,14 +87,10 @@ private async Task LoginAsync() [RelayCommand] private async Task RegisterAsync() { - ErrorMessage = string.Empty; - if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password)) - { - ErrorMessage = "Email and password are required."; - return; - } + if (!ValidateInput()) return; IsBusy = true; + BusyLabel = IsFirstRun ? "Creating your administrator account…" : "Creating your account…"; try { var result = await _identity.RegisterAsync(Email, Password).ConfigureAwait(true); @@ -85,7 +102,7 @@ private async Task RegisterAsync() if (result.IsPendingApproval) { - ErrorMessage = "Account created. An administrator must approve it before you can sign in."; + InfoMessage = "Account created. An administrator must approve it before you can sign in."; return; } diff --git a/src/Sovrant.Desktop/Views/LoginWindow.axaml b/src/Sovrant.Desktop/Views/LoginWindow.axaml index ba3577c8..f685060b 100644 --- a/src/Sovrant.Desktop/Views/LoginWindow.axaml +++ b/src/Sovrant.Desktop/Views/LoginWindow.axaml @@ -4,7 +4,7 @@ x:Class="Sovrant.Desktop.Views.LoginWindow" x:DataType="vm:LoginViewModel" Title="Sovrant — Sign in" - Width="420" Height="460" + Width="420" SizeToContent="Height" CanResize="False" WindowStartupLocation="CenterScreen"> @@ -17,7 +17,22 @@ + Margin="0,0,0,4" + IsVisible="{Binding !IsFirstRun}"/> + + + + + + + @@ -42,15 +57,31 @@ TextWrapping="Wrap" IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> + + + - - @if (_registrationOpen) + @if (_firstRun) + { + + } + else { - - - + + @if (_registrationOpen) + { + + + + @if (_approvalRequired) + { + + } + } } @if (_busy) { + } @@ -66,8 +100,12 @@ private string _email = string.Empty; private string _password = string.Empty; private string? _errorMessage; + private string? _infoMessage; private bool _busy; + private string _busyLabel = string.Empty; private bool _registrationOpen; + private bool _firstRun; + private bool _approvalRequired; protected override async Task OnInitializedAsync() { @@ -76,23 +114,37 @@ Nav.NavigateTo("/", forceLoad: true); return; } + _firstRun = await IdentityService.IsFirstRunAsync().ConfigureAwait(false); _registrationOpen = await IdentityService.IsRegistrationOpenAsync().ConfigureAwait(false); + _approvalRequired = !_firstRun && _registrationOpen + && await IdentityService.IsApprovalRequiredAsync().ConfigureAwait(false); } private async Task OnKeyDown(KeyboardEventArgs e) { - if (e.Key == "Enter") await LoginAsync(); + if (e.Key != "Enter") return; + // On first run there is nobody to sign in as yet — Enter creates the admin account. + if (_firstRun) await RegisterAsync(); + else await LoginAsync(); } - private async Task LoginAsync() + private bool ValidateInput() { _errorMessage = null; + _infoMessage = null; if (string.IsNullOrWhiteSpace(_email) || string.IsNullOrWhiteSpace(_password)) { _errorMessage = "Email and password are required."; - return; + return false; } + return true; + } + + private async Task LoginAsync() + { + if (!ValidateInput()) return; _busy = true; + _busyLabel = "Signing you in…"; try { var result = await IdentityService.LoginAsync(_email, _password).ConfigureAwait(false); @@ -120,13 +172,9 @@ private async Task RegisterAsync() { - _errorMessage = null; - if (string.IsNullOrWhiteSpace(_email) || string.IsNullOrWhiteSpace(_password)) - { - _errorMessage = "Email and password are required."; - return; - } + if (!ValidateInput()) return; _busy = true; + _busyLabel = _firstRun ? "Creating your administrator account…" : "Creating your account…"; try { var result = await IdentityService.RegisterAsync(_email, _password).ConfigureAwait(false); @@ -137,7 +185,7 @@ } if (result.IsPendingApproval) { - _errorMessage = "Account created. An administrator must approve it before you can sign in."; + _infoMessage = "Account created. An administrator must approve it before you can sign in."; return; } await CredentialStore.StoreAsync(StoredTokenKey, result.Token!).ConfigureAwait(false); diff --git a/src/Sovrant.Web/wwwroot/css/sovrant.css b/src/Sovrant.Web/wwwroot/css/sovrant.css index 829c2b41..d255052f 100644 --- a/src/Sovrant.Web/wwwroot/css/sovrant.css +++ b/src/Sovrant.Web/wwwroot/css/sovrant.css @@ -3450,6 +3450,12 @@ details[open] .doc-advanced-toggle::before { content: "▼ "; } .login-sub { font-size: 13px; color: var(--text-secondary); text-align: center; } .login-progress { height: 3px; background: var(--brand-primary); border-radius: 2px; animation: login-pulse 1s ease-in-out infinite; } @keyframes login-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } +.login-info { font-size: 13px; line-height: 1.5; color: var(--text-primary); background: color-mix(in srgb, var(--brand-primary) 10%, transparent); border: 1px solid color-mix(in srgb, var(--brand-primary) 35%, transparent); padding: 10px 12px; border-radius: 5px; } +.login-success { font-size: 13px; line-height: 1.5; color: var(--status-pass); background: color-mix(in srgb, var(--status-pass) 10%, transparent); border: 1px solid color-mix(in srgb, var(--status-pass) 30%, transparent); padding: 8px 10px; border-radius: 5px; } +.login-note { font-size: 12px; color: var(--text-secondary); text-align: center; } +.login-status { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 13px; color: var(--text-secondary); } +.login-spinner { width: 14px; height: 14px; flex: none; border: 2px solid var(--surface-border); border-top-color: var(--brand-primary); border-radius: 50%; animation: login-spin 0.8s linear infinite; } +@keyframes login-spin { to { transform: rotate(360deg); } } /* ===== Admin workspace configure panel ===== */ .admin-config-panel { margin-top: 16px; padding: 16px; background: var(--surface-card); border: 1px solid var(--brand-primary); border-radius: 10px; display: flex; flex-direction: column; gap: 12px; }