@page "/login"
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@inject IJSRuntime JS
@inject NavigationManager Navigation
<PageTitle>Log In - Forge</PageTitle>
<div class="auth-card">
<h1>Log In</h1>
<p class="auth-copy">Use the configured Forge credentials to create repositories and push over Git HTTP.</p>
@if (Error)
{
<p class="auth-error">Invalid username or password.</p>
}
<form method="post" action="/auth/login" class="auth-form">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="@SanitizedReturnUrl" />
<div class="form-group">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required />
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
</div>
<button type="submit" class="btn-primary">Log In</button>
</form>
<div class="auth-divider">
<span>or</span>
</div>
<button type="button" class="btn-passkey" @onclick="SignInWithPasskey" disabled="@passkeyDisabled">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 2C9.243 2 7 4.243 7 7v2H6c-1.103 0-2 .897-2 2v9c0 1.103.897 2 2 2h12c1.103 0 2-.897 2-2v-9c0-1.103-.897-2-2-2h-1V7c0-2.757-2.243-5-5-5zm0 2c1.654 0 3 1.346 3 3v2H9V7c0-1.654 1.346-3 3-3zm0 10c1.103 0 2 .897 2 2s-.897 2-2 2-2-.897-2-2 .897-2 2-2z"/>
</svg>
@if (passkeyDisabled)
{
<span>Passkeys not available</span>
}
else
{
<span>Sign in with Passkey</span>
}
</button>
<p class="passkey-hint">Register a passkey in Settings first</p>
@if (passkeyError != null)
{
<p class="auth-error">@passkeyError</p>
}
</div>
@code {
[SupplyParameterFromQuery] public string? ReturnUrl { get; set; }
[SupplyParameterFromQuery(Name = "error")] public string? ErrorValue { get; set; }
private string SanitizedReturnUrl =>
!string.IsNullOrWhiteSpace(ReturnUrl) && Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative) && ReturnUrl.StartsWith('/')
? ReturnUrl
: "/admin";
private bool Error =>
string.Equals(ErrorValue, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(ErrorValue, "1", StringComparison.Ordinal);
private bool passkeyDisabled = true; // Disabled until we confirm available
private string? passkeyError;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Check if WebAuthn is available (requires HTTPS or localhost)
var available = await JS.InvokeAsync<bool>("forge.webauthn.isAvailable");
passkeyDisabled = !available;
StateHasChanged();
}
}
private async Task SignInWithPasskey()
{
passkeyError = null;
try
{
Console.WriteLine($"[Login] Starting passkey sign-in, WebAuthn available: {!passkeyDisabled}");
var resultJson = await JS.InvokeAsync<string>("forge.webauthn.signIn", SanitizedReturnUrl);
Console.WriteLine($"[Login] Sign-in result: {resultJson}");
var result = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(resultJson);
if (result != null && result.TryGetValue("redirect", out var redirect))
{
Navigation.NavigateTo(redirect, forceLoad: true);
}
else if (result != null && result.TryGetValue("error", out var error))
{
passkeyError = error;
}
else
{
passkeyError = "Passkey authentication failed. Check browser console for details.";
}
}
catch (Exception ex)
{
Console.WriteLine($"[Login] Exception: {ex}");
passkeyError = $"Passkey error: {ex.Message}";
}
}
}