refactor: consolidate project structure by migrating authentication, identity, and shared UI components while removing legacy Web Client files.

This commit is contained in:
2026-04-28 20:23:40 +02:00
parent 131981992c
commit 10efed0369
124 changed files with 2822 additions and 2213 deletions
@@ -0,0 +1,120 @@
@page "/account/login"
@using Microsoft.AspNetCore.Components.Forms
@using NexusReader.UI.Shared.Services
@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>
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
<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>
<a href="/account/forgot-password" class="forgot-link">Forgot password?</a>
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="error-banner">
@_errorMessage
</div>
}
<button type="submit" class="btn-login" disabled="@_isSubmitting">
@if (_isSubmitting)
{
<span class="spinner"></span>
}
else
{
<span>Login</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>
</div>
</div>
@code {
private LoginModel _loginModel = new();
private string? _errorMessage;
private bool _isSubmitting;
private async Task HandleLogin()
{
_isSubmitting = true;
_errorMessage = null;
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;
}
}
private void HandleGoogleLogin()
{
// Redirect to external login endpoint
NavigationManager.NavigateTo("identity/login/google", forceLoad: true);
}
public class LoginModel
{
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.EmailAddress]
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
public string Password { get; set; } = string.Empty;
public bool RememberMe { get; set; }
}
}
@@ -0,0 +1,223 @@
.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;
}
@@ -0,0 +1,79 @@
@page "/account/profile"
@using Microsoft.AspNetCore.Authorization
@using NexusReader.UI.Shared.Services
@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>
<h2>@_profile.Email</h2>
<div class="plan-badge @(_profile.CurrentPlan.ToLower())">
@_profile.CurrentPlan Plan
</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>
</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>
</div>
</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>
@code {
private UserProfile? _profile;
protected override async Task OnInitializedAsync()
{
_profile = await IdentityService.GetProfileAsync();
}
private int CalculateProgress()
{
if (_profile == null || _profile.AITokenLimit == 0) return 0;
var percent = (int)((double)_profile.AITokensUsed / _profile.AITokenLimit * 100);
return Math.Min(percent, 100);
}
private void HandleUpgrade()
{
// Future: Redirect to Stripe billing portal
}
private async Task HandleLogout()
{
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/account/login");
}
}
@@ -0,0 +1,173 @@
.profile-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #0a0a0a;
padding: 2rem;
}
.profile-card {
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);
}
.profile-header {
text-align: center;
margin-bottom: 3rem;
}
.user-avatar {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #39ff14 0%, #1a8a0a 100%);
border-radius: 50%;
margin: 0 auto 1.5rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
color: #000;
box-shadow: 0 0 20px rgba(57, 255, 20, 0.3);
}
.profile-header h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 0.75rem;
}
.plan-badge {
display: inline-block;
padding: 0.4rem 1rem;
border-radius: 2rem;
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;
}
.plan-badge.pro {
background: rgba(57, 255, 20, 0.1);
color: #39ff14;
border: 1px solid rgba(57, 255, 20, 0.2);
}
.profile-stats {
margin-bottom: 3rem;
}
.stat-group {
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;
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);
}
.loading-state {
text-align: center;
padding: 4rem 0;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(57, 255, 20, 0.1);
border-top-color: #39ff14;
border-radius: 50%;
margin: 0 auto 1.5rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,112 @@
@page "/account/register"
@using Microsoft.AspNetCore.Components.Forms
@using NexusReader.UI.Shared.Services
@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>
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
<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>
<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>
<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>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="error-banner">
@_errorMessage
</div>
}
<button type="submit" class="btn-login" disabled="@_isSubmitting">
@if (_isSubmitting)
{
<span class="spinner"></span>
}
else
{
<span>Register</span>
}
</button>
</EditForm>
<div class="login-footer">
<p>Already have an account? <a href="/account/login">Login here</a></p>
</div>
</div>
</div>
@code {
private RegisterModel _registerModel = new();
private string? _errorMessage;
private bool _isSubmitting;
private async Task HandleRegister()
{
if (_registerModel.Password != _registerModel.ConfirmPassword)
{
_errorMessage = "Passwords do not match.";
return;
}
_isSubmitting = true;
_errorMessage = null;
try
{
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.";
}
}
catch (Exception)
{
_errorMessage = "An error occurred during registration. Please try again.";
}
finally
{
_isSubmitting = false;
}
}
public class RegisterModel
{
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.EmailAddress]
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.MinLength(8)]
public string Password { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required]
public string ConfirmPassword { get; set; } = string.Empty;
}
}
@@ -0,0 +1,174 @@
.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); }
}