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
37 changes: 27 additions & 10 deletions src/Sovrant.Desktop/ViewModels/LoginViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>Registration section is only offered once an admin exists and registration is open.</summary>
public bool ShowRegistrationSection => !IsFirstRun && IsRegistrationOpen;

partial void OnIsFirstRunChanged(bool value) => OnPropertyChanged(nameof(ShowRegistrationSection));
partial void OnIsRegistrationOpenChanged(bool value) => OnPropertyChanged(nameof(ShowRegistrationSection));

public event Action<string, string, string>? LoginSucceeded; // (userId, role, email)

Expand All @@ -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);
Comment on lines +44 to +47
}

[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);
Expand All @@ -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);
Expand All @@ -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;
}

Expand Down
46 changes: 42 additions & 4 deletions src/Sovrant.Desktop/Views/LoginWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -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">

Expand All @@ -17,7 +17,22 @@
<TextBlock Text="Sign in to continue."
FontSize="12" Opacity="0.6"
HorizontalAlignment="Center"
Margin="0,0,0,4"/>
Margin="0,0,0,4"
IsVisible="{Binding !IsFirstRun}"/>

<TextBlock Text="Welcome! Let's set up your server."
FontSize="12" Opacity="0.6"
HorizontalAlignment="Center"
Margin="0,0,0,4"
IsVisible="{Binding IsFirstRun}"/>

<!-- First-run explainer: the first account becomes the administrator -->
<Border IsVisible="{Binding IsFirstRun}"
BorderBrush="{DynamicResource SystemAccentColor}"
BorderThickness="1" CornerRadius="5" Padding="10,8">
<TextBlock TextWrapping="Wrap" FontSize="12"
Text="First-time setup — no accounts exist yet. The first account you create becomes the administrator, with full control over registration, approvals, and server settings."/>
</Border>

<!-- Email -->
<StackPanel Spacing="6">
Expand All @@ -42,15 +57,31 @@
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>

<!-- Informational (non-error) message, e.g. pending admin approval -->
<TextBlock Text="{Binding InfoMessage}"
Foreground="Green"
TextWrapping="Wrap"
IsVisible="{Binding InfoMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
Comment on lines +61 to +64

<Button Content="Sign in"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding LoginCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding !IsFirstRun}"
Padding="0,10"/>

<!-- First run: the only action that makes sense is creating the admin account -->
<Button Content="Create administrator account"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding RegisterCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding IsFirstRun}"
Padding="0,10"/>

<!-- Registration option shown only when open -->
<StackPanel Spacing="6" IsVisible="{Binding IsRegistrationOpen}">
<!-- Registration option shown only when open (and not first run) -->
<StackPanel Spacing="6" IsVisible="{Binding ShowRegistrationSection}">
<Separator/>
<TextBlock Text="Don't have an account?"
HorizontalAlignment="Center" Opacity="0.55" FontSize="12"/>
Expand All @@ -60,9 +91,16 @@
Command="{Binding RegisterCommand}"
IsEnabled="{Binding !IsBusy}"
Padding="0,8"/>
<TextBlock Text="New accounts require administrator approval before first sign-in."
HorizontalAlignment="Center" Opacity="0.55" FontSize="11"
TextWrapping="Wrap"
IsVisible="{Binding IsApprovalRequired}"/>
</StackPanel>

<!-- Busy indicator -->
<TextBlock Text="{Binding BusyLabel}"
HorizontalAlignment="Center" Opacity="0.7" FontSize="12"
IsVisible="{Binding IsBusy}"/>
<ProgressBar IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Height="3"/>
Expand Down
88 changes: 68 additions & 20 deletions src/Sovrant.Web/Components/Pages/Login.razor
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,30 @@
<div class="login-card">
<div class="login-title">Sovrant</div>

<div class="login-sub" style="margin-bottom:8px">Sign in to continue.</div>
@if (_firstRun)
{
<div class="login-sub" style="margin-bottom:8px">Welcome! Let's set up your server.</div>
<div class="login-info">
<strong>First-time setup</strong> — no accounts exist yet. The first account you create
becomes the <strong>administrator</strong>, with full control over registration,
approvals, and server settings.
</div>
}
else
{
<div class="login-sub" style="margin-bottom:8px">Sign in to continue.</div>
}

@if (_errorMessage is not null)
{
<div class="login-error">@_errorMessage</div>
}

@if (_infoMessage is not null)
{
<div class="login-success">@_infoMessage</div>
}

<div class="login-field">
<label class="login-label">Email</label>
<input class="login-input"
Expand All @@ -41,21 +58,38 @@
@onkeydown="OnKeyDown" />
</div>

<button class="login-btn" @onclick="LoginAsync" disabled="@_busy">
@(_busy ? "Signing in…" : "Sign in")
</button>

@if (_registrationOpen)
@if (_firstRun)
{
<button class="login-btn" @onclick="RegisterAsync" disabled="@_busy">
Create administrator account
</button>
}
else
{
<div class="login-divider"></div>
<div class="login-sub">Don't have an account?</div>
<button class="login-btn login-btn-secondary" @onclick="RegisterAsync" disabled="@_busy">
@(_busy ? "Creating account…" : "Create account")
<button class="login-btn" @onclick="LoginAsync" disabled="@_busy">
Sign in
</button>

@if (_registrationOpen)
{
<div class="login-divider"></div>
<div class="login-sub">Don't have an account?</div>
<button class="login-btn login-btn-secondary" @onclick="RegisterAsync" disabled="@_busy">
Create account
</button>
@if (_approvalRequired)
{
<div class="login-note">New accounts require administrator approval before first sign-in.</div>
}
}
}

@if (_busy)
{
<div class="login-status" role="status" aria-live="polite">
<span class="login-spinner" aria-hidden="true"></span>
<span>@_busyLabel</span>
</div>
<div class="login-progress"></div>
}
</div>
Expand All @@ -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()
{
Expand All @@ -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);
Comment on lines +117 to +120
}

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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/Sovrant.Web/wwwroot/css/sovrant.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down