@page "/settings"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode InteractiveServer
@using Forge.Web.Auth
@using Forge.Data.Services
@using Microsoft.AspNetCore.Components.Authorization
@inject IAuthService AuthService
@inject IFido2Service Fido2Service
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
<PageTitle>Settings - Forge</PageTitle>
<div class="settings-container">
<h1>Account Settings</h1>
@if (Success)
{
<p class="success-message">Password updated successfully.</p>
}
@if (Error)
{
<p class="auth-error">@ErrorMessage</p>
}
<div class="settings-card">
<h2>Change Password</h2>
<p class="auth-copy">Update your authentication password.</p>
<form method="post" @onsubmit="HandlePasswordChange" class="auth-form">
<AntiforgeryToken />
<div class="form-group">
<label for="current-password">Current Password</label>
<input id="current-password" type="password" @bind="CurrentPassword" autocomplete="current-password" required />
</div>
<div class="form-group">
<label for="new-password">New Password</label>
<input id="new-password" type="password" @bind="NewPassword" autocomplete="new-password" required />
</div>
<div class="form-group">
<label for="confirm-password">Confirm New Password</label>
<input id="confirm-password" type="password" @bind="ConfirmPassword" autocomplete="new-password" required />
</div>
<button type="submit" class="btn-primary">Update Password</button>
</form>
</div>
<div class="settings-card">
<h2>Passkeys</h2>
<p class="auth-copy">Register passkeys for passwordless authentication. Passkeys are stored on your device and synced across your devices via iCloud Keychain, Google Password Manager, or other password managers.</p>
@if (!webauthnAvailable)
{
<p class="auth-warning">WebAuthn is not available. Passkeys require HTTPS (or localhost) and a modern browser.</p>
}
else
{
@if (passkeys.Any())
{
<div class="passkey-list">
@foreach (var passkey in passkeys)
{
<div class="passkey-item">
<div class="passkey-info">
<span class="passkey-name">@(passkey.Name ?? "Unnamed passkey")</span>
<span class="passkey-date">Created @passkey.CreatedAt.ToString("MMM d, yyyy")</span>
@if (passkey.LastUsedAt.HasValue)
{
<span class="passkey-date">Last used @passkey.LastUsedAt.Value.ToString("MMM d, yyyy")</span>
}
</div>
<button type="button" class="btn-danger-small" @onclick="() => DeletePasskey(passkey.Id)">Delete</button>
</div>
}
</div>
}
else
{
<p class="auth-copy">No passkeys registered yet.</p>
}
@if (showRegisterForm)
{
<div class="passkey-register-form">
<div class="form-group">
<label for="device-name">Device Name (optional)</label>
<input id="device-name" type="text" @bind="deviceName" placeholder="e.g., iPhone 15 Pro, MacBook Pro" />
</div>
<div class="button-row">
<button type="button" class="btn-primary" @onclick="RegisterPasskey">Register Passkey</button>
<button type="button" class="btn-secondary" @onclick="() => showRegisterForm = false">Cancel</button>
</div>
</div>
}
else
{
<button type="button" class="btn-primary" @onclick="() => showRegisterForm = true">Add Passkey</button>
}
@if (passkeyError != null)
{
<p class="auth-error">@passkeyError</p>
}
}
</div>
</div>
@code {
[CascadingParameter] private Task<AuthenticationState> AuthState { get; set; } = default!;
[SupplyParameterFromQuery(Name = "success")] public string? SuccessValue { get; set; }
[SupplyParameterFromQuery(Name = "error")] public string? ErrorValue { get; set; }
private string CurrentPassword { get; set; } = string.Empty;
private string NewPassword { get; set; } = string.Empty;
private string ConfirmPassword { get; set; } = string.Empty;
private bool Success => string.Equals(SuccessValue, "true", StringComparison.OrdinalIgnoreCase);
private bool Error => !string.IsNullOrEmpty(ErrorValue);
private string ErrorMessage => ErrorValue ?? "An error occurred.";
private bool webauthnAvailable = false;
private bool showRegisterForm = false;
private string deviceName = string.Empty;
private string? passkeyError;
private List<Forge.Core.Models.PasskeyCredential> passkeys = new();
protected override async Task OnInitializedAsync()
{
var authState = await AuthState;
var username = authState.User.Identity?.Name;
if (!string.IsNullOrEmpty(username))
{
passkeys = (await Fido2Service.GetCredentialsAsync(username)).ToList();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
webauthnAvailable = await JS.InvokeAsync<bool>("forge.webauthn.isAvailable");
Console.WriteLine($"[Settings] WebAuthn available: {webauthnAvailable}");
}
catch (Exception ex)
{
Console.WriteLine($"[Settings] Error checking WebAuthn: {ex.Message}");
webauthnAvailable = false;
}
StateHasChanged();
}
}
private async Task HandlePasswordChange()
{
if (NewPassword != ConfirmPassword)
{
NavigationManager.NavigateTo("/settings?error=Passwords do not match");
return;
}
if (NewPassword.Length < 6)
{
NavigationManager.NavigateTo("/settings?error=Password must be at least 6 characters");
return;
}
var authState = await AuthState;
var username = authState.User.Identity?.Name ?? "admin";
if (!AuthService.ValidateCredentials(username, CurrentPassword))
{
NavigationManager.NavigateTo("/settings?error=Current password is incorrect");
return;
}
try
{
await AuthService.UpdatePasswordAsync(username, NewPassword);
NavigationManager.NavigateTo("/settings?success=true");
}
catch (Exception ex)
{
NavigationManager.NavigateTo($"/settings?error={Uri.EscapeDataString(ex.Message)}");
}
}
private async Task RegisterPasskey()
{
passkeyError = null;
try
{
var result = await JS.InvokeAsync<Dictionary<string, object>>("forge.webauthn.registerDevice", deviceName);
if (result.TryGetValue("success", out var success) && (bool)success)
{
// Refresh passkeys list
var authState = await AuthState;
var username = authState.User.Identity?.Name;
if (!string.IsNullOrEmpty(username))
{
passkeys = (await Fido2Service.GetCredentialsAsync(username)).ToList();
}
showRegisterForm = false;
deviceName = string.Empty;
StateHasChanged();
}
else if (result.TryGetValue("error", out var error))
{
passkeyError = error?.ToString();
}
}
catch (Exception ex)
{
passkeyError = $"Registration failed: {ex.Message}";
}
}
private async Task DeletePasskey(Guid credentialId)
{
var authState = await AuthState;
var username = authState.User.Identity?.Name;
if (!string.IsNullOrEmpty(username))
{
await Fido2Service.DeleteCredentialAsync(credentialId, username);
passkeys = passkeys.Where(p => p.Id != credentialId).ToList();
StateHasChanged();
}
}
}