feat: implement identity authentication, authorization policies, and MAUI platform support with Docker orchestration

This commit is contained in:
2026-04-29 20:37:41 +02:00
parent 10efed0369
commit 0210611edf
55 changed files with 2359 additions and 949 deletions
@@ -28,6 +28,18 @@
case "trash":
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
break;
case "mail":
<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
break;
case "lock":
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
break;
case "eye":
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" />
break;
case "eye-off":
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
break;
default:
<!-- Fallback circle -->
<circle cx="12" cy="12" r="10" />

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -66,7 +66,7 @@
await LoadChapterAsync(NavigationService.CurrentChapterIndex);
}
private async void OnNavigationChanged()
private async Task OnNavigationChanged()
{
_isJsInitialized = false;
_selectedText = string.Empty;
@@ -166,7 +166,7 @@
NavigationService.UpdateMetadata(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
// Trigger full page graph generation after loading
_ = Coordinator.ProcessFullPageAsync(GetFullPageContent());
await Coordinator.ProcessFullPageAsync(GetFullPageContent());
}
else
{
@@ -33,7 +33,13 @@
@code {
protected override void OnInitialized()
{
NavigationService.OnNavigationChanged += StateHasChanged;
NavigationService.OnNavigationChanged += HandleNavigationChanged;
}
private Task HandleNavigationChanged()
{
StateHasChanged();
return Task.CompletedTask;
}
private int CalculateProgress()
@@ -44,6 +50,6 @@
public void Dispose()
{
NavigationService.OnNavigationChanged -= StateHasChanged;
NavigationService.OnNavigationChanged -= HandleNavigationChanged;
}
}
@@ -0,0 +1,16 @@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
if (string.IsNullOrWhiteSpace(returnUrl))
{
NavigationManager.NavigateTo("/account/login");
}
else
{
NavigationManager.NavigateTo($"/account/login?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
}
}
@@ -0,0 +1,24 @@
@inherits LayoutComponentBase
<div class="nexus-auth-shell">
<link rel="stylesheet" href="_content/NexusReader.UI.Shared/css/nexus-auth.css" />
@Body
</div>
<style>
.nexus-auth-shell {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #121418 !important;
display: flex;
justify-content: center;
align-items: center;
z-index: 99999;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
@@ -21,36 +21,33 @@
<div class="resizer" id="sidebar-resizer"></div>
<div class="intelligence-sidebar">
<IntelligenceToolbar />
<div class="intelligence-content">
<div class="intelligence-header">
<div class="ai-title">
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span>
</div>
<AuthorizeView>
<Authorized>
<AuthorizeView>
<Authorized>
<div class="intelligence-sidebar">
<IntelligenceToolbar />
<div class="intelligence-content">
<div class="intelligence-header">
<div class="ai-title">
<NexusIcon Name="robot" Size="20" Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span>
</div>
<div class="user-profile">
<span class="user-email">@context.User.Identity?.Name</span>
<button class="logout-btn" @onclick="HandleLogout">Logout</button>
</div>
</Authorized>
<NotAuthorized>
<a href="/account/login" class="login-link">Login</a>
</NotAuthorized>
</AuthorizeView>
<button class="close-btn">×</button>
<button class="close-btn">×</button>
</div>
<div class="intelligence-scroll-area">
<KnowledgeGraph />
<KnowledgeCheck />
</div>
</div>
</div>
<div class="intelligence-scroll-area">
<KnowledgeGraph />
<KnowledgeCheck />
</div>
</div>
</div>
</Authorized>
</AuthorizeView>
</div>
<div id="blazor-error-ui" data-nosnippet>
@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
@@ -1,69 +1,83 @@
@page "/account/login"
@layout AuthLayout
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Components.Forms
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>NEXUS<span>AI</span></h1>
<p>Welcome back, Reader.</p>
<div class="login-page-container">
<div class="mesh-bg"></div>
<div class="auth-card">
<div class="auth-header">
<div class="logo-box">
<NexusIcon Name="robot" Size="48" />
</div>
<h1 class="app-name">E-Czytnik <span>AI</span></h1>
<p class="auth-subtitle">Zaloguj się do E-Czytnika AI</p>
</div>
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
<div class="social-auth">
<button type="button" class="btn-google-auth" @onclick="HandleGoogleLogin">
<img src="https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png"
alt="Google"
style="width: 20px !important; height: 20px !important; flex-shrink: 0;" />
<span>Zaloguj się przez Google</span>
</button>
</div>
<div class="auth-divider">
<span>lub</span>
</div>
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin" class="auth-form">
<DataAnnotationsValidator />
<div class="form-group">
<label for="email">Email</label>
<InputText id="email" @bind-Value="_loginModel.Email" class="form-control" placeholder="reader@nexus.ai" />
<ValidationMessage For="@(() => _loginModel.Email)" />
</div>
<div class="form-group">
<label for="password">Password</label>
<InputText id="password" type="password" @bind-Value="_loginModel.Password" class="form-control" placeholder="••••••••" />
<ValidationMessage For="@(() => _loginModel.Password)" />
</div>
<div class="form-options">
<div class="remember-me">
<InputCheckbox id="remember" @bind-Value="_loginModel.RememberMe" />
<label for="remember">Remember me</label>
<div class="field-group">
<div class="field-icon">
<NexusIcon Name="mail" Size="18" />
</div>
<a href="/account/forgot-password" class="forgot-link">Forgot password?</a>
<InputText id="email" @bind-Value="_loginModel.Email" placeholder="E-mail" class="field-input" />
</div>
<ValidationMessage For="@(() => _loginModel.Email)" class="auth-validation" />
<div class="field-group">
<div class="field-icon">
<NexusIcon Name="lock" Size="18" />
</div>
<InputText id="password" type="@(_showPassword ? "text" : "password")" @bind-Value="_loginModel.Password" placeholder="Hasło" class="field-input" />
<button type="button" class="toggle-visibility" @onclick="TogglePassword">
<NexusIcon Name="@(_showPassword ? "eye-off" : "eye")" Size="18" />
</button>
</div>
<ValidationMessage For="@(() => _loginModel.Password)" class="auth-validation" />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="error-banner">
@_errorMessage
</div>
<div class="auth-error">@_errorMessage</div>
}
<button type="submit" class="btn-login" disabled="@_isSubmitting">
<button type="submit" class="btn-submit-auth" disabled="@_isSubmitting">
@if (_isSubmitting)
{
<span class="spinner"></span>
<div class="auth-loader"></div>
}
else
{
<span>Login</span>
<span>Zaloguj się</span>
}
</button>
<div class="separator">
<span>OR</span>
</div>
<button type="button" class="btn-google" @onclick="HandleGoogleLogin">
<img src="https://www.gstatic.com/images/branding/product/1x/gsa_512dp.png" alt="Google" />
Continue with Google
</button>
</EditForm>
<div class="login-footer">
<p>Don't have an account? <a href="/account/register">Create one</a></p>
<div class="auth-footer">
<a href="/account/forgot-password" class="auth-link">Zapomniałem hasła?</a>
<p class="auth-switch">Nie masz konta? <a href="/account/register">Zarejestruj się</a></p>
</div>
<div class="auth-legal">
Korzystając z usługi, akceptujesz <a href="/terms">Regulamin</a> i <a href="/privacy">Politykę Prywatności</a>
</div>
</div>
</div>
@@ -72,6 +86,7 @@
private LoginModel _loginModel = new();
private string? _errorMessage;
private bool _isSubmitting;
private bool _showPassword;
private async Task HandleLogin()
{
@@ -81,30 +96,15 @@
try
{
var success = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password);
if (success)
{
NavigationManager.NavigateTo("/");
}
else
{
_errorMessage = "Invalid email or password.";
}
}
catch (Exception)
{
_errorMessage = "An error occurred during login. Please try again.";
}
finally
{
_isSubmitting = false;
if (success) NavigationManager.NavigateTo("/");
else _errorMessage = "Nieprawidłowy e-mail lub hasło.";
}
catch (Exception) { _errorMessage = "Wystąpił błąd logowania."; }
finally { _isSubmitting = false; }
}
private void HandleGoogleLogin()
{
// Redirect to external login endpoint
NavigationManager.NavigateTo("identity/login/google", forceLoad: true);
}
private void HandleGoogleLogin() => NavigationManager.NavigateTo("identity/login/google", forceLoad: true);
private void TogglePassword() => _showPassword = !_showPassword;
public class LoginModel
{
@@ -114,7 +114,5 @@
[System.ComponentModel.DataAnnotations.Required]
public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
}
}
@@ -1,223 +0,0 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #0a0a0a;
background-image: radial-gradient(circle at 50% 50%, #1a1a1a 0%, #0a0a0a 100%);
font-family: 'Inter', sans-serif;
color: #e0e0e0;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2.5rem;
background: rgba(20, 20, 20, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.login-header {
text-align: center;
margin-bottom: 2.5rem;
}
.login-header h1 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.05em;
margin: 0;
color: #ffffff;
}
.login-header h1 span {
color: #39ff14; /* Neon Green */
text-shadow: 0 0 10px rgba(57, 255, 20, 0.5);
}
.login-header p {
color: #888;
margin-top: 0.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: #bbb;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
background: #151515;
border: 1px solid #2a2a2a;
border-radius: 0.75rem;
color: #fff;
font-size: 1rem;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #39ff14;
box-shadow: 0 0 0 4px rgba(57, 255, 20, 0.1);
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.remember-me {
display: flex;
align-items: center;
gap: 0.5rem;
}
.remember-me input {
accent-color: #39ff14;
}
.forgot-link {
color: #888;
text-decoration: none;
transition: color 0.2s;
}
.forgot-link:hover {
color: #39ff14;
}
.btn-login {
width: 100%;
padding: 0.875rem;
background: #39ff14;
color: #000;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
justify-content: center;
align-items: center;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(57, 255, 20, 0.4);
background: #32e612;
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.error-banner {
background: rgba(255, 50, 50, 0.1);
border: 1px solid rgba(255, 50, 50, 0.2);
color: #ff5555;
padding: 0.75rem;
border-radius: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1.5rem;
text-align: center;
}
.login-footer {
margin-top: 2rem;
text-align: center;
font-size: 0.875rem;
color: #888;
}
.login-footer a {
color: #39ff14;
text-decoration: none;
font-weight: 500;
}
.login-footer a:hover {
text-decoration: underline;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.separator {
display: flex;
align-items: center;
text-align: center;
margin: 1.5rem 0;
color: #555;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
}
.separator::before,
.separator::after {
content: '';
flex: 1;
border-bottom: 1px solid #2a2a2a;
}
.separator span {
padding: 0 1rem;
}
.btn-google {
width: 100%;
padding: 0.875rem;
background: #1a1a1a;
color: #fff;
border: 1px solid #333;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
}
.btn-google img {
width: 20px;
height: 20px;
}
.btn-google:hover {
background: #252525;
border-color: #444;
}
@@ -1,54 +1,101 @@
@page "/account/profile"
@using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms
@attribute [Authorize]
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
<div class="profile-container">
<div class="profile-card">
@if (_profile == null)
{
<div class="loading-state">
<div class="spinner"></div>
<p>Fetching your Nexus profile...</p>
</div>
}
else
{
<div class="profile-header">
<div class="user-avatar">
@(string.IsNullOrEmpty(_profile.Email) ? "?" : _profile.Email[0].ToString().ToUpper())
<div class="profile-dashboard">
<div class="mesh-overlay"></div>
@if (_profile == null)
{
<div class="loading-overlay">
<div class="nexus-loader"></div>
<p>Ładowanie Twojego profilu...</p>
</div>
}
else
{
<div class="dashboard-content">
<header class="dashboard-header">
<div class="user-meta">
<div class="user-avatar">
@(_profile.Email[0].ToString().ToUpper())
</div>
<div class="user-info">
<h1>@_profile.Email</h1>
<div class="plan-info">
<span class="badge @(_profile.CurrentPlan.ToLower())">@_profile.CurrentPlan Plan</span>
<span class="tenant-id">ID: @_profile.TenantId.ToString()[..8]...</span>
</div>
</div>
</div>
<h2>@_profile.Email</h2>
<div class="plan-badge @(_profile.CurrentPlan.ToLower())">
@_profile.CurrentPlan Plan
<div class="header-actions">
<button class="btn-logout" @onclick="HandleLogout">
<NexusIcon Name="lock" Size="16" />
Wyloguj się
</button>
</div>
</header>
<div class="stats-grid">
<!-- AI Token Card -->
<div class="stat-card usage-card">
<div class="card-icon">
<NexusIcon Name="robot" Size="24" />
</div>
<div class="card-info">
<h3>Wykorzystanie AI</h3>
<div class="token-numbers">
<span class="tokens-used">@_profile.AITokensUsed</span>
<span class="tokens-limit">/ @_profile.AITokenLimit tokenów</span>
</div>
<div class="usage-bar">
<div class="usage-fill" style="width: @(CalculateProgress())%"></div>
</div>
<p class="usage-desc">Limit odnawia się w następnym cyklu rozliczeniowym.</p>
</div>
</div>
<!-- Learning Progress Card -->
<div class="stat-card learning-card">
<div class="card-icon">
<NexusIcon Name="mail" Size="24" />
</div>
<div class="card-info">
<h3>Aktywna Nauka</h3>
<div class="learning-metrics">
<div class="metric">
<span class="label">Średni wynik quizów</span>
<span class="value">@_profile.AverageQuizScore%</span>
</div>
<div class="metric">
<span class="label">Ostatnio czytane</span>
<span class="value truncate">@_profile.LastReadBookTitle</span>
</div>
</div>
</div>
</div>
</div>
<div class="profile-stats">
<div class="stat-group">
<div class="stat-header">
<span>AI Token Usage</span>
<span class="usage-count">@_profile.AITokensUsed / @_profile.AITokenLimit</span>
<section class="subscription-section">
<div class="section-card">
<div class="section-info">
<h2>Zarządzaj subskrypcją</h2>
<p>Zmień swój plan, aby zwiększyć limit tokenów AI i odblokować funkcje premium.</p>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: @(CalculateProgress())%"></div>
</div>
<p class="stat-footer">Resetting on your next billing date.</p>
<button class="btn-upgrade" @onclick="HandleUpgrade">
Przejdź do panelu płatności
</button>
</div>
</div>
</section>
</div>
}
<div class="profile-actions">
<button class="btn-primary" @onclick="HandleUpgrade">
Manage Subscription
</button>
<button class="btn-outline" @onclick="HandleLogout">
Sign Out
</button>
</div>
}
</div>
<div class="decoration-star top-left">✦</div>
<div class="decoration-star bottom-right">✦</div>
</div>
@code {
@@ -1,173 +1,256 @@
.profile-container {
.profile-dashboard {
position: relative;
width: 100%;
min-height: 100vh;
background-color: #121418;
color: white;
font-family: 'Inter', sans-serif;
padding: 60px 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #0a0a0a;
padding: 2rem;
overflow-x: hidden;
}
.profile-card {
.mesh-overlay {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background-image: radial-gradient(circle at 2px 2px, rgba(255,255,255,0.02) 1px, transparent 0);
background-size: 40px 40px;
z-index: 1;
}
.dashboard-content {
position: relative;
width: 100%;
max-width: 500px;
background: rgba(20, 20, 20, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 2rem;
padding: 3rem;
box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.6);
max-width: 1000px;
z-index: 10;
}
.profile-header {
text-align: center;
margin-bottom: 3rem;
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 48px;
padding: 0 10px;
}
.user-meta {
display: flex;
align-items: center;
gap: 24px;
}
.user-avatar {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #39ff14 0%, #1a8a0a 100%);
border-radius: 50%;
margin: 0 auto 1.5rem;
background: linear-gradient(135deg, #44ff77 0%, #2ecc71 100%);
border-radius: 24px;
display: flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
font-size: 2.5rem;
font-weight: 800;
color: #000;
box-shadow: 0 0 20px rgba(57, 255, 20, 0.3);
box-shadow: 0 10px 30px rgba(68, 255, 119, 0.2);
}
.profile-header h2 {
font-size: 1.5rem;
.user-info h1 {
font-size: 1.8rem;
font-weight: 700;
margin: 0 0 0.75rem;
margin: 0 0 8px;
letter-spacing: -0.02em;
}
.plan-badge {
display: inline-block;
padding: 0.4rem 1rem;
border-radius: 2rem;
.plan-info {
display: flex;
align-items: center;
gap: 12px;
}
.badge {
padding: 4px 12px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.plan-badge.free {
background: rgba(255, 255, 255, 0.1);
color: #888;
}
.badge.pro { background: rgba(68, 255, 119, 0.1); color: #44ff77; border: 1px solid rgba(68, 255, 119, 0.2); }
.badge.free { background: rgba(255, 255, 255, 0.05); color: #888; border: 1px solid rgba(255, 255, 255, 0.1); }
.plan-badge.pro {
background: rgba(57, 255, 20, 0.1);
color: #39ff14;
border: 1px solid rgba(57, 255, 20, 0.2);
}
.tenant-id { font-size: 0.8rem; color: #555; }
.profile-stats {
margin-bottom: 3rem;
}
.stat-group {
.btn-logout {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
background: rgba(255, 255, 255, 0.03);
padding: 1.5rem;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.stat-header {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 1rem;
color: #bbb;
}
.usage-count {
color: #fff;
font-weight: 700;
}
.progress-bar {
width: 100%;
height: 8px;
background: #1a1a1a;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-fill {
height: 100%;
background: #39ff14;
box-shadow: 0 0 10px rgba(57, 255, 20, 0.5);
border-radius: 4px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.stat-footer {
font-size: 0.75rem;
color: #666;
margin: 0;
}
.profile-actions {
display: flex;
flex-direction: column;
gap: 1rem;
}
.btn-primary {
width: 100%;
padding: 1rem;
background: #39ff14;
color: #000;
border: none;
border-radius: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(57, 255, 20, 0.3);
}
.btn-outline {
width: 100%;
padding: 1rem;
background: transparent;
color: #ff5555;
border: 1px solid rgba(255, 85, 85, 0.2);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: #888;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-outline:hover {
background: rgba(255, 85, 85, 0.05);
border-color: rgba(255, 85, 85, 0.4);
.btn-logout:hover {
background: rgba(255, 77, 77, 0.05);
border-color: rgba(255, 77, 77, 0.2);
color: #ff4d4d;
}
.loading-state {
text-align: center;
padding: 4rem 0;
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
margin-bottom: 48px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(57, 255, 20, 0.1);
border-top-color: #39ff14;
.stat-card {
background: #1c1f24;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
padding: 32px;
display: flex;
gap: 24px;
transition: transform 0.3s ease;
}
.stat-card:hover { transform: translateY(-5px); }
.card-icon {
width: 56px;
height: 56px;
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
display: flex;
justify-content: center;
align-items: center;
color: #44ff77;
flex-shrink: 0;
}
.card-info h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 16px;
color: #e0e0e0;
}
.token-numbers {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 12px;
}
.tokens-used { font-size: 2rem; font-weight: 800; color: white; }
.tokens-limit { font-size: 1rem; color: #555; font-weight: 500; }
.usage-bar {
width: 100%;
height: 8px;
background: #15181c;
border-radius: 10px;
overflow: hidden;
margin-bottom: 12px;
}
.usage-fill {
height: 100%;
background: #44ff77;
box-shadow: 0 0 15px rgba(68, 255, 119, 0.3);
border-radius: 10px;
}
.usage-desc { font-size: 0.8rem; color: #555; margin: 0; }
.learning-metrics {
display: flex;
flex-direction: column;
gap: 20px;
}
.metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.metric .label { font-size: 0.85rem; color: #666; font-weight: 500; }
.metric .value { font-size: 1.2rem; font-weight: 700; color: white; }
.metric .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
.subscription-section { margin-top: 48px; }
.section-card {
background: linear-gradient(90deg, #1c1f24 0%, #23272e 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28px;
padding: 40px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 40px;
}
.section-info h2 { font-size: 1.5rem; font-weight: 700; margin: 0 0 12px; }
.section-info p { color: #888; margin: 0; line-height: 1.6; max-width: 500px; }
.btn-upgrade {
padding: 16px 32px;
background: #44ff77;
border: none;
border-radius: 16px;
color: #000;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-upgrade:hover {
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(68, 255, 119, 0.2);
}
.loading-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.nexus-loader {
width: 48px;
height: 48px;
border: 3px solid rgba(68, 255, 119, 0.1);
border-top-color: #44ff77;
border-radius: 50%;
margin: 0 auto 1.5rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
@keyframes spin { to { transform: rotate(360deg); } }
.decoration-star {
position: absolute;
font-size: 48px;
color: rgba(255, 255, 255, 0.03);
z-index: 1;
}
.top-left { top: 40px; left: 40px; }
.bottom-right { bottom: 40px; right: 40px; }
@media (max-width: 768px) {
.dashboard-header { flex-direction: column; align-items: flex-start; gap: 24px; }
.header-actions { width: 100%; }
.btn-logout { width: 100%; justify-content: center; }
.stats-grid { grid-template-columns: 1fr; }
.section-card { flex-direction: column; text-align: center; }
}
@@ -1,58 +1,70 @@
@page "/account/register"
@layout AuthLayout
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Components.Forms
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Components.Atoms
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>NEXUS<span>AI</span></h1>
<p>Join the future of reading.</p>
<div class="login-page-container">
<div class="mesh-bg"></div>
<div class="auth-card">
<div class="auth-header">
<div class="logo-box">
<NexusIcon Name="robot" Size="48" />
</div>
<h1 class="app-name">Nexus <span>Reader</span></h1>
<p class="auth-subtitle">Utwórz nowe konto</p>
</div>
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister" class="auth-form">
<DataAnnotationsValidator />
<div class="form-group">
<label for="email">Email</label>
<InputText id="email" @bind-Value="_registerModel.Email" class="form-control" placeholder="reader@nexus.ai" />
<ValidationMessage For="@(() => _registerModel.Email)" />
<div class="field-group">
<div class="field-icon">
<NexusIcon Name="mail" Size="18" />
</div>
<InputText id="email" @bind-Value="_registerModel.Email" placeholder="E-mail" class="field-input" />
</div>
<ValidationMessage For="@(() => _registerModel.Email)" class="auth-validation" />
<div class="form-group">
<label for="password">Password</label>
<InputText id="password" type="password" @bind-Value="_registerModel.Password" class="form-control" placeholder="Min 8 chars, uppercase, digit" />
<ValidationMessage For="@(() => _registerModel.Password)" />
<div class="field-group">
<div class="field-icon">
<NexusIcon Name="lock" Size="18" />
</div>
<InputText id="password" type="password" @bind-Value="_registerModel.Password" placeholder="Hasło" class="field-input" />
</div>
<ValidationMessage For="@(() => _registerModel.Password)" class="auth-validation" />
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<InputText id="confirm-password" type="password" @bind-Value="_registerModel.ConfirmPassword" class="form-control" placeholder="••••••••" />
<ValidationMessage For="@(() => _registerModel.ConfirmPassword)" />
<div class="field-group">
<div class="field-icon">
<NexusIcon Name="lock" Size="18" />
</div>
<InputText id="confirmPassword" type="password" @bind-Value="_registerModel.ConfirmPassword" placeholder="Potwierdź hasło" class="field-input" />
</div>
<ValidationMessage For="@(() => _registerModel.ConfirmPassword)" class="auth-validation" />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="error-banner">
@_errorMessage
</div>
<div class="auth-error">@_errorMessage</div>
}
<button type="submit" class="btn-login" disabled="@_isSubmitting">
<button type="submit" class="btn-submit-auth" disabled="@_isSubmitting">
@if (_isSubmitting)
{
<span class="spinner"></span>
<div class="auth-loader"></div>
}
else
{
<span>Register</span>
<span>Zarejestruj się</span>
}
</button>
</EditForm>
<div class="login-footer">
<p>Already have an account? <a href="/account/login">Login here</a></p>
<div class="auth-footer">
<p class="auth-switch">Masz już konto? <a href="/account/login">Zaloguj się</a></p>
</div>
</div>
</div>
@@ -64,12 +76,6 @@
private async Task HandleRegister()
{
if (_registerModel.Password != _registerModel.ConfirmPassword)
{
_errorMessage = "Passwords do not match.";
return;
}
_isSubmitting = true;
_errorMessage = null;
@@ -78,35 +84,27 @@
var success = await IdentityService.RegisterAsync(_registerModel.Email, _registerModel.Password);
if (success)
{
// Registration successful, redirect to login
NavigationManager.NavigateTo("/account/login?registered=true");
}
else
{
_errorMessage = "Registration failed. Email might already be in use.";
var loginSuccess = await IdentityService.LoginAsync(_registerModel.Email, _registerModel.Password);
if (loginSuccess) NavigationManager.NavigateTo("/");
else NavigationManager.NavigateTo("/account/login");
}
else { _errorMessage = "Rejestracja nie powiodła się."; }
}
catch (Exception)
{
_errorMessage = "An error occurred during registration. Please try again.";
}
finally
{
_isSubmitting = false;
}
catch (Exception) { _errorMessage = "Wystąpił błąd podczas rejestracji."; }
finally { _isSubmitting = false; }
}
public class RegisterModel
{
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.EmailAddress]
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "E-mail jest wymagany")]
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Nieprawidłowy e-mail")]
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.MinLength(8)]
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Hasło jest wymagane")]
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Minimum 8 znaków")]
public string Password { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Hasła nie są identyczne")]
public string ConfirmPassword { get; set; } = string.Empty;
}
}
@@ -1,174 +0,0 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #0a0a0a;
background-image: radial-gradient(circle at 50% 50%, #1a1a1a 0%, #0a0a0a 100%);
font-family: 'Inter', sans-serif;
color: #e0e0e0;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2.5rem;
background: rgba(20, 20, 20, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.login-header {
text-align: center;
margin-bottom: 2.5rem;
}
.login-header h1 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.05em;
margin: 0;
color: #ffffff;
}
.login-header h1 span {
color: #39ff14; /* Neon Green */
text-shadow: 0 0 10px rgba(57, 255, 20, 0.5);
}
.login-header p {
color: #888;
margin-top: 0.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: #bbb;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
background: #151515;
border: 1px solid #2a2a2a;
border-radius: 0.75rem;
color: #fff;
font-size: 1rem;
transition: all 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: #39ff14;
box-shadow: 0 0 0 4px rgba(57, 255, 20, 0.1);
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.remember-me {
display: flex;
align-items: center;
gap: 0.5rem;
}
.remember-me input {
accent-color: #39ff14;
}
.forgot-link {
color: #888;
text-decoration: none;
transition: color 0.2s;
}
.forgot-link:hover {
color: #39ff14;
}
.btn-login {
width: 100%;
padding: 0.875rem;
background: #39ff14;
color: #000;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
justify-content: center;
align-items: center;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(57, 255, 20, 0.4);
background: #32e612;
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.error-banner {
background: rgba(255, 50, 50, 0.1);
border: 1px solid rgba(255, 50, 50, 0.2);
color: #ff5555;
padding: 0.75rem;
border-radius: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1.5rem;
text-align: center;
}
.login-footer {
margin-top: 2rem;
text-align: center;
font-size: 0.875rem;
color: #888;
}
.login-footer a {
color: #39ff14;
text-decoration: none;
font-weight: 500;
}
.login-footer a:hover {
text-decoration: underline;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -1,4 +1,5 @@
@page "/"
@attribute [Authorize]
@using NexusReader.UI.Shared.Services
@implements IAsyncDisposable
@inject IQuizStateService QuizState
+1 -1
View File
@@ -4,7 +4,7 @@
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)">
<NotAuthorized>
<p role="alert">You are not authorized to access this resource. Please login.</p>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
@@ -6,10 +6,10 @@ public interface IReaderNavigationService
int TotalChapters { get; }
string ChapterTitle { get; }
event Action OnNavigationChanged;
event Func<Task>? OnNavigationChanged;
void GoToChapter(int index);
void GoToNextChapter();
void GoToPreviousChapter();
Task GoToChapter(int index);
Task GoToNextChapter();
Task GoToPreviousChapter();
void UpdateMetadata(int currentIndex, int totalChapters, string title);
}
@@ -9,6 +9,7 @@ public interface IIdentityService
Task<bool> LoginAsync(string email, string password);
Task LogoutAsync();
Task<UserProfile?> GetProfileAsync();
Task<bool> RefreshTokenAsync();
}
public record UserProfile(
@@ -16,7 +17,9 @@ public record UserProfile(
int AITokenLimit,
int AITokensUsed,
string CurrentPlan,
Guid TenantId);
Guid TenantId,
int AverageQuizScore,
string LastReadBookTitle);
public class IdentityService : IIdentityService
{
@@ -24,6 +27,7 @@ public class IdentityService : IIdentityService
private readonly INativeStorageService _storageService;
private readonly NexusAuthenticationStateProvider _authStateProvider;
private const string TokenKey = "nexus_auth_token";
private const string RefreshTokenKey = "nexus_refresh_token";
public IdentityService(
HttpClient httpClient,
@@ -51,6 +55,10 @@ public class IdentityService : IIdentityService
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
{
await _storageService.SaveSecureString(TokenKey, result.AccessToken);
if (!string.IsNullOrEmpty(result.RefreshToken))
{
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
}
_authStateProvider.NotifyUserAuthentication(result.AccessToken);
return true;
}
@@ -62,6 +70,7 @@ public class IdentityService : IIdentityService
public async Task LogoutAsync()
{
_storageService.RemoveSecure(TokenKey);
_storageService.RemoveSecure(RefreshTokenKey);
_authStateProvider.NotifyUserLogout();
}
@@ -77,6 +86,33 @@ public class IdentityService : IIdentityService
}
}
public async Task<bool> RefreshTokenAsync()
{
var result = await _storageService.GetSecureString(RefreshTokenKey);
var refreshToken = result.IsSuccess ? result.Value : null;
if (string.IsNullOrEmpty(refreshToken)) return false;
var response = await _httpClient.PostAsJsonAsync("identity/refresh", new { refreshToken });
if (response.IsSuccessStatusCode)
{
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken))
{
await _storageService.SaveSecureString(TokenKey, loginResult.AccessToken);
if (!string.IsNullOrEmpty(loginResult.RefreshToken))
{
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
}
_authStateProvider.NotifyUserAuthentication(loginResult.AccessToken);
return true;
}
}
return false;
}
private class LoginResponse
{
public string TokenType { get; set; } = string.Empty;
@@ -1,3 +1,5 @@
using System.Linq;
namespace NexusReader.UI.Shared.Services;
public class ReaderNavigationService : IReaderNavigationService
@@ -6,29 +8,29 @@ public class ReaderNavigationService : IReaderNavigationService
public int TotalChapters { get; private set; } = 1;
public string ChapterTitle { get; private set; } = "Loading...";
public event Action? OnNavigationChanged;
public event Func<Task>? OnNavigationChanged;
public void GoToChapter(int index)
public async Task GoToChapter(int index)
{
if (index < 0 || index >= TotalChapters) return;
CurrentChapterIndex = index;
OnNavigationChanged?.Invoke();
await NotifyNavigationChangedAsync();
}
public void GoToNextChapter()
public async Task GoToNextChapter()
{
if (CurrentChapterIndex < TotalChapters - 1)
{
GoToChapter(CurrentChapterIndex + 1);
await GoToChapter(CurrentChapterIndex + 1);
}
}
public void GoToPreviousChapter()
public async Task GoToPreviousChapter()
{
if (CurrentChapterIndex > 0)
{
GoToChapter(CurrentChapterIndex - 1);
await GoToChapter(CurrentChapterIndex - 1);
}
}
@@ -41,7 +43,21 @@ public class ReaderNavigationService : IReaderNavigationService
if (changed)
{
OnNavigationChanged?.Invoke();
// Note: UpdateMetadata remains void, so we trigger notification fire-and-forget here
// but usually this is called during a render cycle where metadata is updated from a load.
_ = NotifyNavigationChangedAsync();
}
}
private async Task NotifyNavigationChangedAsync()
{
var handlers = OnNavigationChanged?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers.Cast<Func<Task>>())
{
await handler();
}
}
}
}
@@ -0,0 +1,84 @@
using FluentResults;
using Microsoft.JSInterop;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.UI.Shared.Services;
public class WebStorageService : INativeStorageService
{
private readonly IJSRuntime _jsRuntime;
public WebStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public Result SaveString(string key, string value)
{
try
{
_jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result<string?> GetString(string key)
{
return Result.Fail("Use GetStringAsync or similar if available, or call from async context.");
}
public Result SaveBool(string key, bool value) => SaveString(key, value.ToString());
public Result<bool> GetBool(string key, bool defaultValue = false)
{
return Result.Ok(defaultValue);
}
public Result Remove(string key)
{
try
{
_jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result> SaveSecureString(string key, string value)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result<string?>> GetSecureString(string key)
{
try
{
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
return Result.Ok(value);
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result RemoveSecure(string key)
{
return Remove(key);
}
}
+2
View File
@@ -9,6 +9,8 @@
@using Microsoft.JSInterop
@using NexusReader.UI.Shared
@using NexusReader.UI.Shared.Layout
@using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Components
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Organisms
@@ -0,0 +1,98 @@
:root {
--nexus-primary: #44ff77;
--nexus-bg: #121418;
--nexus-card-bg: #1c1f24;
--nexus-border: rgba(255, 255, 255, 0.08);
--nexus-text-muted: #666;
--nexus-text-bright: #e2e8f0;
}
.login-page-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--nexus-bg);
color: white;
font-family: 'Inter', sans-serif;
}
.mesh-bg {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background-image: radial-gradient(circle at 2px 2px, rgba(255,255,255,0.03) 1px, transparent 0);
background-size: 40px 40px;
opacity: 0.5;
z-index: 1;
}
.auth-card {
position: relative;
width: 100%;
max-width: 420px;
padding: 40px;
background: var(--nexus-card-bg);
border: 1px solid var(--nexus-border);
border-radius: 24px;
box-shadow: 0 30px 60px rgba(0,0,0,0.4);
z-index: 10;
text-align: center;
}
.auth-header { margin-bottom: 24px; }
.logo-box { margin-bottom: 12px; color: white; }
.app-name { font-size: 1.5rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.app-name span { color: var(--nexus-primary); }
.auth-subtitle { font-size: 1.1rem; color: #bbb; margin-top: 8px; }
.btn-google-auth {
width: 100%; display: flex; justify-content: center; align-items: center; gap: 12px;
padding: 12px; background: transparent; border: 1px solid #3d424a; border-radius: 12px;
color: var(--nexus-text-bright); font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.2s;
}
.btn-google-auth:hover { background: rgba(255,255,255,0.05); }
.auth-divider { display: flex; align-items: center; margin: 20px 0; color: var(--nexus-text-muted); font-size: 0.8rem; }
.auth-divider::before, .auth-divider::after { content: ""; flex: 1; height: 1px; background: #2d3239; }
.auth-divider span { padding: 0 12px; }
.auth-form { display: flex; flex-direction: column; gap: 16px; }
.field-group { position: relative; display: flex; align-items: center; }
.field-icon { position: absolute; left: 16px; color: var(--nexus-text-muted); pointer-events: none; z-index: 5; }
.field-input {
width: 100% !important; padding: 14px 16px 14px 48px !important;
background: #15181c !important; border: 1px solid #2d3239 !important;
border-radius: 12px !important; color: white !important; font-size: 0.9rem !important;
outline: none !important; transition: border-color 0.2s;
}
.field-input:focus { border-color: var(--nexus-primary) !important; }
.toggle-visibility { position: absolute; right: 16px; background: none; border: none; color: var(--nexus-text-muted); cursor: pointer; padding: 4px; z-index: 5; }
.btn-submit-auth {
width: 100%; padding: 14px; background: var(--nexus-primary); border: none; border-radius: 12px;
color: #000; font-size: 0.95rem; font-weight: 700; cursor: pointer; transition: all 0.2s;
margin-top: 8px;
}
.btn-submit-auth:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(68, 255, 119, 0.2); }
.auth-footer { margin-top: 24px; display: flex; flex-direction: column; gap: 8px; }
.auth-link { color: var(--nexus-text-muted); text-decoration: none; font-size: 0.85rem; }
.auth-switch { color: var(--nexus-text-muted); font-size: 0.9rem; margin: 0; }
.auth-switch a { color: white; text-decoration: none; font-weight: 600; }
.auth-switch a:hover { color: var(--nexus-primary); }
.auth-legal { margin-top: 32px; font-size: 0.7rem; color: #444; line-height: 1.4; }
.auth-legal a { color: #555; }
.auth-validation { color: #ff4d4d; font-size: 0.75rem; text-align: left; margin-top: 4px; }
.auth-error { color: #ff4d4d; font-size: 0.85rem; text-align: center; }
.auth-loader { width: 18px; height: 18px; border: 2px solid rgba(0,0,0,0.1); border-radius: 50%; border-top-color: #000; animation: spin 0.8s linear infinite; margin: 0 auto; }
@keyframes spin { to { transform: rotate(360deg); } }