Phase 4B AuthService
This commit is contained in:
parent
6f42c65e35
commit
a294df4e14
78
PlotLine/Controllers/AccountController.cs
Normal file
78
PlotLine/Controllers/AccountController.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Controllers;
|
||||
|
||||
public sealed class AccountController(IAuthService authService) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Register(string? returnUrl = null)
|
||||
{
|
||||
return View(new RegisterViewModel());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Register(RegisterViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var result = await authService.RegisterAsync(model);
|
||||
if (!result.Success)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Registration could not be completed.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
TempData["AuthMessage"] = "Your account has been created. You can now log in.";
|
||||
return RedirectToAction(nameof(Login));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Login(string? returnUrl = null)
|
||||
{
|
||||
return View(new LoginViewModel { ReturnUrl = returnUrl });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Login(LoginViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var result = await authService.LoginAsync(model);
|
||||
if (!result.Success)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Login could not be completed.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl))
|
||||
{
|
||||
return LocalRedirect(model.ReturnUrl);
|
||||
}
|
||||
|
||||
return RedirectToAction("Index", "Home");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await authService.LogoutAsync();
|
||||
return RedirectToAction("Index", "Home");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult AccessDenied()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ public interface IUserRepository
|
||||
{
|
||||
Task<AppUser?> GetByEmailAsync(string email);
|
||||
Task<AppUser?> GetByIdAsync(int userId);
|
||||
Task<AppUser> CreateAsync(string email, string displayName, string passwordHash);
|
||||
Task<AppUser> CreateAsync(string email, string displayName, string passwordHash, bool emailConfirmed);
|
||||
Task<AppUser?> UpdateLoginSuccessAsync(int userId);
|
||||
Task<AppUser?> UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc);
|
||||
Task<bool> ConfirmEmailAsync(int userId, Guid token);
|
||||
@ -48,12 +48,12 @@ public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IU
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<AppUser> CreateAsync(string email, string displayName, string passwordHash)
|
||||
public async Task<AppUser> CreateAsync(string email, string displayName, string passwordHash, bool emailConfirmed)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<AppUser>(
|
||||
"dbo.User_Create",
|
||||
new { Email = email, DisplayName = displayName, PasswordHash = passwordHash },
|
||||
new { Email = email, DisplayName = displayName, PasswordHash = passwordHash, EmailConfirmed = emailConfirmed },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
|
||||
21
PlotLine/Models/AuthResultModels.cs
Normal file
21
PlotLine/Models/AuthResultModels.cs
Normal file
@ -0,0 +1,21 @@
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public class AuthenticationResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public bool RequiresTwoFactor { get; init; }
|
||||
|
||||
public static AuthenticationResult Succeeded() => new() { Success = true };
|
||||
|
||||
public static AuthenticationResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage };
|
||||
}
|
||||
|
||||
public sealed class RegistrationResult : AuthenticationResult
|
||||
{
|
||||
public int? UserID { get; init; }
|
||||
|
||||
public static RegistrationResult Succeeded(int userId) => new() { Success = true, UserID = userId };
|
||||
|
||||
public new static RegistrationResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage };
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>7ebffbd4-1a5b-4053-840e-b52f93e756b8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -16,6 +16,7 @@ public class Program
|
||||
builder.Logging.AddDebug();
|
||||
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
@ -87,11 +88,11 @@ public class Program
|
||||
builder.Services.AddScoped<IImportService, ImportService>();
|
||||
builder.Services.AddSingleton<IHelpService, HelpService>();
|
||||
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
|
||||
builder.Services.AddScoped<IAuthService>(_ => throw new NotImplementedException("Authentication business logic will be implemented in a later phase."));
|
||||
builder.Services.AddScoped<IPasswordService>(_ => throw new NotImplementedException("Password hashing will be implemented in a later phase."));
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IPasswordService, PasswordService>();
|
||||
builder.Services.AddScoped<ITwoFactorService>(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase."));
|
||||
builder.Services.AddScoped<IEmailService>(_ => throw new NotImplementedException("Authentication email sending will be implemented in a later phase."));
|
||||
builder.Services.AddScoped<ICurrentUserService>(_ => throw new NotImplementedException("Current user access will be implemented in a later phase."));
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@ -1,19 +1,20 @@
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<bool> RegisterAsync(RegisterViewModel model);
|
||||
Task<bool> LoginAsync(LoginViewModel model);
|
||||
Task<bool> CompleteTwoFactorLoginAsync(TwoFactorViewModel model);
|
||||
Task<RegistrationResult> RegisterAsync(RegisterViewModel model);
|
||||
Task<AuthenticationResult> LoginAsync(LoginViewModel model);
|
||||
Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model);
|
||||
Task LogoutAsync();
|
||||
Task<bool> VerifyEmailAsync(Guid token);
|
||||
Task<bool> ForgotPasswordAsync(ForgotPasswordViewModel model);
|
||||
Task<bool> ResetPasswordAsync(ResetPasswordViewModel model);
|
||||
Task<bool> ChangePasswordAsync(ChangePasswordViewModel model);
|
||||
Task<bool> EnableTwoFactorAsync(int userId);
|
||||
Task<bool> DisableTwoFactorAsync(int userId);
|
||||
Task<AuthenticationResult> VerifyEmailAsync(Guid token);
|
||||
Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model);
|
||||
Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model);
|
||||
Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model);
|
||||
Task<AuthenticationResult> EnableTwoFactorAsync(int userId);
|
||||
Task<AuthenticationResult> DisableTwoFactorAsync(int userId);
|
||||
Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId);
|
||||
}
|
||||
|
||||
|
||||
165
PlotLine/Services/AuthServices.cs
Normal file
165
PlotLine/Services/AuthServices.cs
Normal file
@ -0,0 +1,165 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public sealed class PasswordService : IPasswordService
|
||||
{
|
||||
private readonly PasswordHasher<AppUser> _passwordHasher = new();
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return _passwordHasher.HashPassword(new AppUser(), password);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string passwordHash)
|
||||
{
|
||||
var result = _passwordHasher.VerifyHashedPassword(new AppUser(), passwordHash, password);
|
||||
return result is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService
|
||||
{
|
||||
private ClaimsPrincipal? User => httpContextAccessor.HttpContext?.User;
|
||||
|
||||
public int? UserId
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = User?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return int.TryParse(value, out var userId) ? userId : null;
|
||||
}
|
||||
}
|
||||
|
||||
public string? Email => User?.FindFirstValue(ClaimTypes.Email);
|
||||
|
||||
public string? DisplayName => User?.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
public bool IsAuthenticated => User?.Identity?.IsAuthenticated == true;
|
||||
}
|
||||
|
||||
public sealed class AuthService(
|
||||
IUserRepository users,
|
||||
IAuthenticationRepository authentication,
|
||||
IPasswordService passwords,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<AuthService> logger) : IAuthService
|
||||
{
|
||||
public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
|
||||
{
|
||||
var email = Clean(model.Email);
|
||||
var displayName = Clean(model.DisplayName);
|
||||
|
||||
var existingUser = await users.GetByEmailAsync(email);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return RegistrationResult.Failed("An account already exists for that email address.");
|
||||
}
|
||||
|
||||
var passwordHash = passwords.HashPassword(model.Password);
|
||||
var created = await users.CreateAsync(email, displayName, passwordHash, emailConfirmed: true);
|
||||
logger.LogInformation("User {UserID} registered with email confirmation temporarily enabled for Phase 4B.", created.UserID);
|
||||
|
||||
return RegistrationResult.Succeeded(created.UserID);
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> LoginAsync(LoginViewModel model)
|
||||
{
|
||||
var email = Clean(model.Email);
|
||||
var user = await users.GetByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
await InsertLoginAuditAsync(null, email, false, "User not found");
|
||||
return AuthenticationResult.Failed("The email address or password was not recognised.");
|
||||
}
|
||||
|
||||
if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
|
||||
{
|
||||
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc: null);
|
||||
await InsertLoginAuditAsync(user.UserID, email, false, "Invalid password");
|
||||
return AuthenticationResult.Failed("The email address or password was not recognised.");
|
||||
}
|
||||
|
||||
var updatedUser = await users.UpdateLoginSuccessAsync(user.UserID) ?? user;
|
||||
await InsertLoginAuditAsync(user.UserID, email, true, "Login successful");
|
||||
await SignInAsync(updatedUser, model.RememberMe);
|
||||
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is not null)
|
||||
{
|
||||
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model)
|
||||
=> throw new NotImplementedException("Two-factor login will be implemented in a later phase.");
|
||||
|
||||
public Task<AuthenticationResult> VerifyEmailAsync(Guid token)
|
||||
=> throw new NotImplementedException("Email verification will be implemented in a later phase.");
|
||||
|
||||
public Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model)
|
||||
=> throw new NotImplementedException("Forgotten password handling will be implemented in a later phase.");
|
||||
|
||||
public Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model)
|
||||
=> throw new NotImplementedException("Password reset handling will be implemented in a later phase.");
|
||||
|
||||
public Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
|
||||
=> throw new NotImplementedException("Password changes will be implemented in a later phase.");
|
||||
|
||||
public Task<AuthenticationResult> EnableTwoFactorAsync(int userId)
|
||||
=> throw new NotImplementedException("Two-factor setup will be implemented in a later phase.");
|
||||
|
||||
public Task<AuthenticationResult> DisableTwoFactorAsync(int userId)
|
||||
=> throw new NotImplementedException("Two-factor setup will be implemented in a later phase.");
|
||||
|
||||
public Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId)
|
||||
=> throw new NotImplementedException("Recovery codes will be implemented in a later phase.");
|
||||
|
||||
private async Task SignInAsync(AppUser user, bool rememberMe)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.UserID.ToString()),
|
||||
new(ClaimTypes.Name, user.DisplayName),
|
||||
new(ClaimTypes.Email, user.Email)
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var properties = new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = rememberMe,
|
||||
AllowRefresh = true
|
||||
};
|
||||
|
||||
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No active HTTP context is available.");
|
||||
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties);
|
||||
}
|
||||
|
||||
private async Task InsertLoginAuditAsync(int? userId, string emailAttempted, bool wasSuccessful, string reason)
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
await authentication.InsertLoginAuditAsync(new UserLoginAudit
|
||||
{
|
||||
UserID = userId,
|
||||
EmailAttempted = emailAttempted,
|
||||
IpAddress = httpContext?.Connection.RemoteIpAddress?.ToString(),
|
||||
UserAgent = httpContext?.Request.Headers.UserAgent.ToString(),
|
||||
WasSuccessful = wasSuccessful,
|
||||
Reason = reason
|
||||
});
|
||||
}
|
||||
|
||||
private static string Clean(string value) => value.Trim();
|
||||
}
|
||||
22
PlotLine/Sql/038_Phase4B_RegisterLoginLogout.sql
Normal file
22
PlotLine/Sql/038_Phase4B_RegisterLoginLogout.sql
Normal file
@ -0,0 +1,22 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.User_Create
|
||||
@Email nvarchar(256),
|
||||
@DisplayName nvarchar(200),
|
||||
@PasswordHash nvarchar(max),
|
||||
@EmailConfirmed bit = 0
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
INSERT INTO dbo.AppUser (Email, DisplayName, PasswordHash, EmailConfirmed)
|
||||
VALUES (@Email, @DisplayName, @PasswordHash, @EmailConfirmed);
|
||||
|
||||
DECLARE @UserID int = SCOPE_IDENTITY();
|
||||
|
||||
EXEC dbo.User_GetById @UserID = @UserID;
|
||||
END;
|
||||
GO
|
||||
17
PlotLine/Views/Account/AccessDenied.cshtml
Normal file
17
PlotLine/Views/Account/AccessDenied.cshtml
Normal file
@ -0,0 +1,17 @@
|
||||
@{
|
||||
ViewData["Title"] = "Access denied";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Access denied</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="edit-panel">
|
||||
<div class="alert alert-warning mb-3">
|
||||
You do not have access to that part of PlotWeaver Studio.
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Home" asp-action="Index">Back to Home</a>
|
||||
</section>
|
||||
52
PlotLine/Views/Account/Login.cshtml
Normal file
52
PlotLine/Views/Account/Login.cshtml
Normal file
@ -0,0 +1,52 @@
|
||||
@model LoginViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Login";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Login</h1>
|
||||
<p class="lead-text">Sign in to PlotWeaver Studio.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["AuthMessage"] is string authMessage)
|
||||
{
|
||||
<div class="alert alert-info">@authMessage</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel">
|
||||
<form asp-action="Login" method="post" class="asset-create-form">
|
||||
<input asp-for="ReturnUrl" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Email">Email address</label>
|
||||
<input class="form-control" asp-for="Email" autocomplete="email" />
|
||||
<span class="text-danger" asp-validation-for="Email"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Password">Password</label>
|
||||
<input class="form-control" asp-for="Password" autocomplete="current-password" />
|
||||
<span class="text-danger" asp-validation-for="Password"></span>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" asp-for="RememberMe" />
|
||||
<span class="form-check-label">Remember me</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Login</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="Register">Create an account</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
50
PlotLine/Views/Account/Register.cshtml
Normal file
50
PlotLine/Views/Account/Register.cshtml
Normal file
@ -0,0 +1,50 @@
|
||||
@model RegisterViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Register";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Create your account</h1>
|
||||
<p class="lead-text">Create a PlotWeaver Studio account so your workspace can use the new authentication foundation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="edit-panel">
|
||||
<form asp-action="Register" method="post" class="asset-create-form">
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="DisplayName">Display name</label>
|
||||
<input class="form-control" asp-for="DisplayName" autocomplete="name" />
|
||||
<span class="text-danger" asp-validation-for="DisplayName"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Email">Email address</label>
|
||||
<input class="form-control" asp-for="Email" autocomplete="email" />
|
||||
<span class="text-danger" asp-validation-for="Email"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Password">Password</label>
|
||||
<input class="form-control" asp-for="Password" autocomplete="new-password" />
|
||||
<span class="text-danger" asp-validation-for="Password"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="ConfirmPassword">Confirm password</label>
|
||||
<input class="form-control" asp-for="ConfirmPassword" autocomplete="new-password" />
|
||||
<span class="text-danger" asp-validation-for="ConfirmPassword"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Register</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="Login">Already have an account?</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
@ -53,9 +53,23 @@
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">About</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="theme-switcher btn-group btn-group-sm" role="group" aria-label="Theme selection">
|
||||
<button type="button" class="btn btn-outline-secondary" data-theme-choice="light" aria-pressed="true">Light</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-theme-choice="dark" aria-pressed="false">Dark</button>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<span class="navbar-text text-dark">Hello @User.Identity.Name</span>
|
||||
<form asp-controller="Account" asp-action="Logout" method="post" class="m-0">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Logout</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm" asp-controller="Account" asp-action="Login">Login</a>
|
||||
<a class="btn btn-outline-primary btn-sm" asp-controller="Account" asp-action="Register">Register</a>
|
||||
}
|
||||
<div class="theme-switcher btn-group btn-group-sm" role="group" aria-label="Theme selection">
|
||||
<button type="button" class="btn btn-outline-secondary" data-theme-choice="light" aria-pressed="true">Light</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-theme-choice="dark" aria-pressed="false">Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user