mirror of
https://github.com/LD-Reborn/Berufsschule_HAM.git
synced 2025-12-20 06:51:55 +00:00
Merge pull request #48 from LD-Reborn/17-create-a-login-page-that-uses-the-ldap-users-for-authentication
Added UserModel, Added Login and Logout and authorization, Made dark …
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Berufsschule_HAM.Models;
|
using Berufsschule_HAM.Models;
|
||||||
using Novell.Directory.Ldap;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using System.Security.Claims;
|
||||||
using Berufsschule_HAM.Services;
|
using Berufsschule_HAM.Services;
|
||||||
|
using ElmahCore;
|
||||||
|
using Berufsschule_HAM.Exceptions;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
[Route("[controller]")]
|
[Route("[controller]")]
|
||||||
@@ -16,11 +19,72 @@ public class HomeController : Controller
|
|||||||
_ldap = ldap ?? throw new ArgumentNullException(nameof(ldap));
|
_ldap = ldap ?? throw new ArgumentNullException(nameof(ldap));
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: /Assets
|
[Authorize]
|
||||||
[HttpGet("Index")]
|
[HttpGet("Index")]
|
||||||
[HttpGet("/")]
|
[HttpGet("/")]
|
||||||
public IActionResult Index()
|
public IActionResult Index()
|
||||||
{
|
{
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("Login")]
|
||||||
|
public async Task<ActionResult> Login(string username, string password)
|
||||||
|
{
|
||||||
|
var authenticationResult = await _ldap.AuthenticateUser(username, password);
|
||||||
|
if (authenticationResult.Success)
|
||||||
|
{
|
||||||
|
List<Claim> claims =
|
||||||
|
[
|
||||||
|
new(ClaimTypes.Name, username)
|
||||||
|
];
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(
|
||||||
|
claims,
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
var authProperties = new AuthenticationProperties
|
||||||
|
{
|
||||||
|
IsPersistent = true,
|
||||||
|
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(300)
|
||||||
|
};
|
||||||
|
|
||||||
|
await HttpContext.SignInAsync(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
new ClaimsPrincipal(claimsIdentity),
|
||||||
|
authProperties);
|
||||||
|
|
||||||
|
return RedirectToAction("Index", "Home");
|
||||||
|
}
|
||||||
|
switch (authenticationResult.AuthenticationState)
|
||||||
|
{
|
||||||
|
case UserNotAuthenticatedReason.InvalidCredentials:
|
||||||
|
return View(new LoginViewModel() { ErrorText = "Invalid login credentials" }); // TODO add localization (e.g. T["Invalid login credentials"]; see: https://learn.microsoft.com/de-de/dotnet/core/extensions/localization)
|
||||||
|
case UserNotAuthenticatedReason.UserLockedOut:
|
||||||
|
return View(new LoginViewModel() { ErrorText = "Your account has been locked. Wait a few minutes or ask an administrator to unlock you" }); // TODO add localization (e.g. T["Invalid login credentials"]; see: https://learn.microsoft.com/de-de/dotnet/core/extensions/localization)
|
||||||
|
case UserNotAuthenticatedReason.UserNotAuthorized:
|
||||||
|
return View(new LoginViewModel() { ErrorText = "You are not authorized for login. Ask an administrator to authorize you." }); // TODO add localization (e.g. T["Invalid login credentials"]; see: https://learn.microsoft.com/de-de/dotnet/core/extensions/localization)
|
||||||
|
default:
|
||||||
|
await HttpContext.RaiseError(new HellFrozeOverException());
|
||||||
|
return View(new LoginViewModel() { ErrorText = "Hell froze over. Make a screenshot and send it to an administrator." }); // TODO add localization (e.g. T["Invalid login credentials"]; see: https://learn.microsoft.com/de-de/dotnet/core/extensions/localization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Login")]
|
||||||
|
public ActionResult Login()
|
||||||
|
{
|
||||||
|
return View(new LoginViewModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("Logout")]
|
||||||
|
public ActionResult Logout()
|
||||||
|
{
|
||||||
|
HttpContext.SignOutAsync();
|
||||||
|
return RedirectToAction("Index", "Home");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("AccessDenied")]
|
||||||
|
public ActionResult AccessDenied()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ public class UsersController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("Index")]
|
[HttpGet("Index")]
|
||||||
public async Task<IEnumerable<Dictionary<string, string>>> Index(UsersIndexRequestModel requestModel)
|
public async Task<IEnumerable<UserModel>> Index(UsersIndexRequestModel requestModel)
|
||||||
{
|
{
|
||||||
string? uid = requestModel.Uid;
|
string? uid = requestModel.Uid;
|
||||||
List<string> attributes = ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"];
|
List<string> attributes = ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"];
|
||||||
@@ -96,12 +96,12 @@ public class UsersController : Controller
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
string uid = requestModel.uid;
|
string uid = requestModel.uid;
|
||||||
Dictionary<string, string>? user = null;
|
UserModel? user = null;
|
||||||
if (requestModel.Cn is not null)
|
if (requestModel.Cn is not null)
|
||||||
{
|
{
|
||||||
await _ldap.UpdateUser(uid, "cn", requestModel.Cn);
|
await _ldap.UpdateUser(uid, "cn", requestModel.Cn);
|
||||||
user ??= await _ldap.GetUserByUidAsync(uid);
|
user ??= await _ldap.GetUserByUidAsync(uid);
|
||||||
string newUid = user["sn"].ToLower() + requestModel.Cn.ToLower();
|
string newUid = user.Sn?.ToLower() + requestModel.Cn.ToLower();
|
||||||
await _ldap.UpdateUser(uid, "uid", newUid);
|
await _ldap.UpdateUser(uid, "uid", newUid);
|
||||||
uid = newUid;
|
uid = newUid;
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ public class UsersController : Controller
|
|||||||
{
|
{
|
||||||
await _ldap.UpdateUser(uid, "sn", requestModel.Sn);
|
await _ldap.UpdateUser(uid, "sn", requestModel.Sn);
|
||||||
user ??= await _ldap.GetUserByUidAsync(uid);
|
user ??= await _ldap.GetUserByUidAsync(uid);
|
||||||
string newUid = requestModel.Sn.ToLower() + user["cn"].ToLower();
|
string newUid = requestModel.Sn.ToLower() + user.Cn?.ToLower();
|
||||||
await _ldap.UpdateUser(uid, "uid", newUid);
|
await _ldap.UpdateUser(uid, "uid", newUid);
|
||||||
uid = newUid;
|
uid = newUid;
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/Exceptions/HellFrozeOverException.cs
Normal file
3
src/Exceptions/HellFrozeOverException.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Berufsschule_HAM.Exceptions;
|
||||||
|
|
||||||
|
public class HellFrozeOverException : Exception {}
|
||||||
6
src/Models/LoginViewModel.cs
Normal file
6
src/Models/LoginViewModel.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Berufsschule_HAM.Models;
|
||||||
|
|
||||||
|
class LoginViewModel
|
||||||
|
{
|
||||||
|
public string? ErrorText { get; set; }
|
||||||
|
}
|
||||||
36
src/Models/UserModels.cs
Normal file
36
src/Models/UserModels.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
namespace Berufsschule_HAM.Models;
|
||||||
|
|
||||||
|
public class UserModel
|
||||||
|
{
|
||||||
|
public required string Uid { get; set; }
|
||||||
|
public string? Cn { get; set; }
|
||||||
|
public string? Sn { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? JpegPhoto { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? UserPassword { get; set; }
|
||||||
|
public UserModel(Dictionary<string, string> ldapData)
|
||||||
|
{
|
||||||
|
Uid = ldapData.GetValueOrDefault("uid") ?? throw new ArgumentException("UID is required");
|
||||||
|
Cn = ldapData.GetValueOrDefault("cn");
|
||||||
|
Sn = ldapData.GetValueOrDefault("sn");
|
||||||
|
Description = ldapData.GetValueOrDefault("description");
|
||||||
|
JpegPhoto = ldapData.GetValueOrDefault("jpegPhoto");
|
||||||
|
Title = ldapData.GetValueOrDefault("title");
|
||||||
|
UserPassword = ldapData.GetValueOrDefault("userPassword");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UserAuthenticationResult
|
||||||
|
{
|
||||||
|
public required bool Success;
|
||||||
|
public UserNotAuthenticatedReason AuthenticationState { get; set; } = UserNotAuthenticatedReason.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UserNotAuthenticatedReason
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
InvalidCredentials,
|
||||||
|
UserNotAuthorized,
|
||||||
|
UserLockedOut
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using ElmahCore;
|
using ElmahCore;
|
||||||
using ElmahCore.Mvc;
|
using ElmahCore.Mvc;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Berufsschule_HAM.Services;
|
using Berufsschule_HAM.Services;
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -26,6 +27,14 @@ builder.Services.AddElmah<XmlFileErrorLog>(Options =>
|
|||||||
|
|
||||||
builder.Services.AddSingleton<LdapService>();
|
builder.Services.AddSingleton<LdapService>();
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(options =>
|
||||||
|
{
|
||||||
|
options.LoginPath = "/Home/Login";
|
||||||
|
options.LogoutPath = "/Home/Login/Logout";
|
||||||
|
options.AccessDeniedPath = "/Home/AccessDenied";
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using Novell.Directory.Ldap;
|
using Novell.Directory.Ldap;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Berufsschule_HAM.Models;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Berufsschule_HAM.Services;
|
namespace Berufsschule_HAM.Services;
|
||||||
public class LdapService : IDisposable
|
public partial class LdapService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly LdapOptions _opts;
|
private readonly LdapOptions _opts;
|
||||||
private readonly LdapConnection _conn;
|
private readonly LdapConnection _conn;
|
||||||
@@ -49,19 +53,30 @@ public class LdapService : IDisposable
|
|||||||
return await ListObjectBy(UsersBaseDn, "", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"]);
|
return await ListObjectBy(UsersBaseDn, "", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Dictionary<string, string>>> ListUsersAsync(string[] attributes)
|
public async Task<IEnumerable<UserModel>> ListUsersAsync(string[] attributes)
|
||||||
{
|
{
|
||||||
return await ListObjectBy(UsersBaseDn, "", attributes);
|
List<UserModel> returnValue = [];
|
||||||
|
(await ListObjectBy(UsersBaseDn, "", attributes))
|
||||||
|
.ToList()
|
||||||
|
.ForEach(x =>
|
||||||
|
returnValue.Add(
|
||||||
|
new UserModel(x)
|
||||||
|
{
|
||||||
|
Uid = x["uid"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return returnValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<string, string>> GetUserByUidAsync(string uid)
|
public async Task<UserModel> GetUserByUidAsync(string uid)
|
||||||
{
|
{
|
||||||
return (await ListObjectBy(UsersBaseDn, $"uid={uid}", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"])).First();
|
return new UserModel((await ListObjectBy(UsersBaseDn, $"uid={uid}", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"])).First()) {Uid = uid};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<string, string>> GetUserByUidAsync(string uid, string[] attributes)
|
public async Task<UserModel> GetUserByUidAsync(string uid, string[] attributes)
|
||||||
{
|
{
|
||||||
return (await ListObjectBy(UsersBaseDn, $"uid={uid}", attributes)).First();
|
return new UserModel((await ListObjectBy(UsersBaseDn, $"uid={uid}", attributes)).First()) {Uid = uid};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +101,46 @@ public class LdapService : IDisposable
|
|||||||
CreateObject(LocationsBaseDn, attributeSet);
|
CreateObject(LocationsBaseDn, attributeSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UserAuthenticationResult> AuthenticateUser(string username, string password)
|
||||||
|
{
|
||||||
|
await ConnectAndBind();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UserModel user = await GetUserByUidAsync(username);
|
||||||
|
if (user.UserPassword is null)
|
||||||
|
{
|
||||||
|
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
|
||||||
|
}
|
||||||
|
if (CompareStringToSha256(password, user.UserPassword))
|
||||||
|
{
|
||||||
|
return new() { Success = true};
|
||||||
|
}
|
||||||
|
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
|
||||||
|
}
|
||||||
|
catch (LdapException)
|
||||||
|
{
|
||||||
|
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CompareStringToSha256(string sourcePassword, string targetPasswordHashed)
|
||||||
|
{
|
||||||
|
byte[] sourcePasswordBytes = SHA256.HashData(Encoding.UTF8.GetBytes(sourcePassword));
|
||||||
|
byte[] targetPasswordHashedBytes = Convert.FromBase64String(CurlyBracesRemover().Replace(targetPasswordHashed, ""));
|
||||||
|
if (sourcePasswordBytes.Length != targetPasswordHashedBytes.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < sourcePasswordBytes.Length; i++)
|
||||||
|
{
|
||||||
|
if (sourcePasswordBytes[i] != targetPasswordHashedBytes[i])
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private string PrependRDN(string rdn, string dn)
|
private string PrependRDN(string rdn, string dn)
|
||||||
{
|
{
|
||||||
return rdn + "," + dn;
|
return rdn + "," + dn;
|
||||||
@@ -166,4 +221,7 @@ public class LdapService : IDisposable
|
|||||||
_conn.Disconnect();
|
_conn.Disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{.*?\}")]
|
||||||
|
private static partial Regex CurlyBracesRemover();
|
||||||
}
|
}
|
||||||
9
src/Views/Home/AccessDenied.cshtml
Normal file
9
src/Views/Home/AccessDenied.cshtml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Access denied";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="display-4">Access denied</h1>
|
||||||
|
<p>You currently do not have permission to access the page you tried to access.</p>
|
||||||
|
<a href="/Home/Login">Please click here to return to the login page</a>
|
||||||
|
</div>
|
||||||
28
src/Views/Home/Login.cshtml
Normal file
28
src/Views/Home/Login.cshtml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Login";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="display-4">Login</h1>
|
||||||
|
@{
|
||||||
|
if (Model.ErrorText is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger text-center login-error">
|
||||||
|
<h2>@Model.ErrorText</h2>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<form method="post" action="/Home/Login" class="mt-4" style="max-width: 400px; margin: auto;">
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||||
<link rel="stylesheet" href="~/Berufsschule_HAM.styles.css" asp-append-version="true" />
|
<link rel="stylesheet" href="~/Berufsschule_HAM.styles.css" asp-append-version="true" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-bs-theme="dark">
|
||||||
<header>
|
<header>
|
||||||
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
|
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar bg border-bottom box-shadow mb-3">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">Berufsschule_HAM</a>
|
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">Berufsschule_HAM</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
|
||||||
@@ -21,10 +21,17 @@
|
|||||||
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
||||||
<ul class="navbar-nav flex-grow-1">
|
<ul class="navbar-nav flex-grow-1">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
|
<a class="nav-link text" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
|
@if (User.Identity.IsAuthenticated)
|
||||||
|
{
|
||||||
|
<a class="nav-link text" asp-area="" asp-controller="Home" asp-action="Logout">Log out</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="nav-link text" asp-area="" asp-controller="Home" asp-action="Login">Login</a>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +46,7 @@
|
|||||||
|
|
||||||
<footer class="border-top footer text-muted">
|
<footer class="border-top footer text-muted">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
© 2025 - Berufsschule_HAM - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
|
© 2025 - Berufsschule_HAM
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
<script src="~/lib/jquery/dist/jquery.min.js"></script>
|
||||||
|
|||||||
@@ -28,4 +28,10 @@ body {
|
|||||||
|
|
||||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 1rem 0 1rem 0;
|
||||||
|
padding: 2rem 4rem 2rem 4rem;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user