Created a guestlist website
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
||||
# Build results
|
||||
bin/
|
||||
obj/
|
||||
Debug/
|
||||
Release/
|
||||
x64/
|
||||
x86/
|
||||
arm/
|
||||
arm64/
|
||||
|
||||
# .NET generated files
|
||||
*.user
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
artifacts/
|
||||
TestResults/
|
||||
coverage/
|
||||
coverage.*
|
||||
|
||||
# Rider / JetBrains
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# Visual Studio / VS Code
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
wwwroot/publish/
|
||||
|
||||
# Node / frontend tooling, if added later
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Guestlist", "Guestlist\Guestlist.csproj", "{8C03CD70-B769-4B98-B947-88BBA9E2968D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{8C03CD70-B769-4B98-B947-88BBA9E2968D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8C03CD70-B769-4B98-B947-88BBA9E2968D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8C03CD70-B769-4B98-B947-88BBA9E2968D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8C03CD70-B769-4B98-B947-88BBA9E2968D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,19 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class AccessCodeDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
public string CodeHash { get; set; } = "";
|
||||
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class AccessCodeService(MongoDbContext db)
|
||||
{
|
||||
private const string GuestAccessCodeId = "guest-access";
|
||||
|
||||
private readonly IMongoCollection<AccessCodeDocument> _accessCodes =
|
||||
db.Database.GetCollection<AccessCodeDocument>("access_codes");
|
||||
|
||||
private readonly PasswordHasher<AccessCodeDocument> _hasher = new();
|
||||
|
||||
public async Task SetGuestAccessCodeAsync(string code, string label = "Guest access")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new InvalidOperationException("Guest access code cannot be empty.");
|
||||
}
|
||||
|
||||
var document = new AccessCodeDocument
|
||||
{
|
||||
Id = GuestAccessCodeId,
|
||||
Label = label,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
document.CodeHash = _hasher.HashPassword(document, code);
|
||||
|
||||
await _accessCodes.ReplaceOneAsync(
|
||||
x => x.Id == GuestAccessCodeId,
|
||||
document,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
|
||||
public async Task<bool> HasGuestAccessCodeAsync()
|
||||
{
|
||||
return await _accessCodes.CountDocumentsAsync(x => x.Id == GuestAccessCodeId && x.IsActive) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateGuestAccessCodeAsync(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var document = await _accessCodes
|
||||
.Find(x => x.Id == GuestAccessCodeId && x.IsActive)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.ExpiresAt is not null && document.ExpiresAt <= DateTime.UtcNow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = _hasher.VerifyHashedPassword(
|
||||
document,
|
||||
document.CodeHash,
|
||||
code);
|
||||
|
||||
return result == PasswordVerificationResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class AppSettingsDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = "global";
|
||||
|
||||
public bool IsSetupComplete { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"_id": "global",
|
||||
"isSetupComplete": true,
|
||||
"createdAt": "..."
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"_id": "...",
|
||||
"codeHash": "...",
|
||||
"createdAt": "..."
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"_id": "...",
|
||||
"email": "admin@example.com",
|
||||
"displayName": "Admin",
|
||||
"passwordHash": "...",
|
||||
"role": "Admin",
|
||||
"isActive": true,
|
||||
"createdAt": "..."
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="/"/>
|
||||
<ResourcePreloader/>
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]"/>
|
||||
<link rel="stylesheet" href="@Assets["app.css"]"/>
|
||||
<link rel="stylesheet" href="@Assets["Guestlist.styles.css"]"/>
|
||||
<ImportMap/>
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes/>
|
||||
<ReconnectModal/>
|
||||
<script src="@Assets["lib/bootstrap/dist/js/bootstrap.bundle.min.js"]"></script>
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<NavMenu/>
|
||||
|
||||
<main>
|
||||
<article class="content container-fluid">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f6f7f9;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: calc(100vh - 57px);
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1320px;
|
||||
padding: 2rem 1rem 3rem;
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: #fff3cd;
|
||||
border-top: 1px solid #ffecb5;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.08);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.75rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
|
||||
<nav class="navbar navbar-expand-lg bg-white border-bottom sticky-top">
|
||||
<div class="container-fluid">
|
||||
<NavLink class="navbar-brand fw-semibold" href="" Match="NavLinkMatch.All">
|
||||
Guestlist
|
||||
</NavLink>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavigation"
|
||||
aria-controls="mainNavigation" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="mainNavigation">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">Home</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="list">Guest list</NavLink>
|
||||
</li>
|
||||
|
||||
<AuthorizeView Roles="Admin,Maintainer">
|
||||
<Authorized>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="guests">Manage guests</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
<AuthorizeView Roles="Admin">
|
||||
<Authorized>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="admin">Admin</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="setup">Setup</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
<AuthorizeView>
|
||||
<NotAuthorized>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="login">Login</NavLink>
|
||||
</li>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<form method="post" action="/auth/logout" class="d-flex">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,16 @@
|
||||
.navbar {
|
||||
min-height: 57px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #0d6efd;
|
||||
background: #eef5ff;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br/>Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br/>Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
@page "/admin"
|
||||
@rendermode InteractiveServer
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
<div class="mb-4">
|
||||
<h1 class="h2 mb-1">Admin</h1>
|
||||
<p class="text-secondary mb-0">Manage access, maintainers and setup recovery.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel-grid">
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Guest access</h2>
|
||||
<p class="text-secondary">Change the code guests use to view the list.</p>
|
||||
|
||||
<form @onsubmit="UpdateGuestAccessCode" @onsubmit:preventDefault="true">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="guest-access-code">New guest access code</label>
|
||||
<input id="guest-access-code" class="form-control" @bind="NewGuestAccessCode" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Update guest access code
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(AccessCodeMessage))
|
||||
{
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">@AccessCodeMessage</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Create maintainer</h2>
|
||||
<p class="text-secondary">Maintainers can add, edit and delete guests.</p>
|
||||
|
||||
<form @onsubmit="CreateMaintainer" @onsubmit:preventDefault="true">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="maintainer-username">Username</label>
|
||||
<input id="maintainer-username" class="form-control" @bind="MaintainerUsername" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="maintainer-name">Name</label>
|
||||
<input id="maintainer-name" class="form-control" @bind="MaintainerDisplayName" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="maintainer-password">Password</label>
|
||||
<input id="maintainer-password" class="form-control" type="password" @bind="MaintainerPassword" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Create maintainer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(MaintainerMessage))
|
||||
{
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">@MaintainerMessage</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Maintainers</h2>
|
||||
|
||||
@if (Maintainers is null)
|
||||
{
|
||||
<p class="text-secondary mb-0">Loading maintainers...</p>
|
||||
}
|
||||
else if (Maintainers.Count == 0)
|
||||
{
|
||||
<p class="text-secondary mb-0">No maintainers have been created yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach (var maintainer in Maintainers)
|
||||
{
|
||||
<div class="list-group-item px-0 d-flex align-items-center justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold">@maintainer.DisplayName</div>
|
||||
<div class="small text-secondary">@maintainer.Username</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteMaintainer(maintainer.Id)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Setup recovery</h2>
|
||||
<p class="text-secondary">Use this after a partial setup if admin access exists but setup is incomplete.</p>
|
||||
|
||||
<button type="button" class="btn btn-outline-primary" @onclick="CompleteSetupRecovery">
|
||||
Complete setup recovery
|
||||
</button>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(RecoveryMessage))
|
||||
{
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">@RecoveryMessage</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card shadow-sm border-danger-subtle">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Reset setup</h2>
|
||||
<p class="text-secondary">Move existing users to backup and mark setup as incomplete.</p>
|
||||
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="ResetSetup">
|
||||
Reset setup
|
||||
</button>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0" role="alert">@Message</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,182 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Guestlist.Components.Pages;
|
||||
|
||||
public partial class Admin
|
||||
{
|
||||
[Inject]
|
||||
protected SetupStatusService SetupStatusService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected UserService UserService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected AccessCodeService AccessCodeService { get; set; } = default!;
|
||||
|
||||
protected string Message { get; set; } = "";
|
||||
|
||||
protected string NewGuestAccessCode { get; set; } = "";
|
||||
protected string AccessCodeMessage { get; set; } = "";
|
||||
protected string RecoveryMessage { get; set; } = "";
|
||||
|
||||
protected string MaintainerUsername { get; set; } = "";
|
||||
protected string MaintainerDisplayName { get; set; } = "";
|
||||
protected string MaintainerPassword { get; set; } = "";
|
||||
protected string MaintainerMessage { get; set; } = "";
|
||||
protected List<UserDocument>? Maintainers { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Maintainers = await UserService.GetMaintainersAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
MaintainerMessage = "Maintainers could not be loaded.";
|
||||
Maintainers = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task ResetSetup()
|
||||
{
|
||||
Message = "";
|
||||
|
||||
try
|
||||
{
|
||||
await UserService.BackupAndClearUsersAsync("Setup reset from admin panel");
|
||||
await SetupStatusService.ResetSetupAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Message = "Setup could not be reset.";
|
||||
return;
|
||||
}
|
||||
|
||||
Message = "Setup has been reset. Existing users were moved to backup.";
|
||||
}
|
||||
|
||||
protected async Task UpdateGuestAccessCode()
|
||||
{
|
||||
AccessCodeMessage = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewGuestAccessCode))
|
||||
{
|
||||
AccessCodeMessage = "Provide a guest access code.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await AccessCodeService.SetGuestAccessCodeAsync(NewGuestAccessCode);
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
AccessCodeMessage = exception.Message;
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
AccessCodeMessage = "Guest access code could not be updated.";
|
||||
return;
|
||||
}
|
||||
|
||||
NewGuestAccessCode = "";
|
||||
AccessCodeMessage = "Guest access code has been updated.";
|
||||
}
|
||||
|
||||
protected async Task CompleteSetupRecovery()
|
||||
{
|
||||
RecoveryMessage = "";
|
||||
|
||||
try
|
||||
{
|
||||
if (!await AccessCodeService.HasGuestAccessCodeAsync())
|
||||
{
|
||||
RecoveryMessage = "Set a guest access code before completing recovery.";
|
||||
return;
|
||||
}
|
||||
|
||||
await SetupStatusService.MarkSetupCompleteAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
RecoveryMessage = "Setup recovery could not be completed.";
|
||||
return;
|
||||
}
|
||||
|
||||
RecoveryMessage = "Setup recovery has been completed.";
|
||||
}
|
||||
|
||||
protected async Task CreateMaintainer()
|
||||
{
|
||||
MaintainerMessage = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(MaintainerUsername))
|
||||
{
|
||||
MaintainerMessage = "Provide a username.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(MaintainerPassword))
|
||||
{
|
||||
MaintainerMessage = "Provide a password.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (MaintainerPassword.Length < 8)
|
||||
{
|
||||
MaintainerMessage = "Password must be at least 8 characters long.";
|
||||
return;
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(MaintainerDisplayName)
|
||||
? MaintainerUsername
|
||||
: MaintainerDisplayName;
|
||||
|
||||
try
|
||||
{
|
||||
await UserService.CreateMaintainerAsync(displayName, MaintainerUsername, MaintainerPassword);
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
MaintainerMessage = exception.Message;
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
MaintainerMessage = "Maintainer could not be created.";
|
||||
return;
|
||||
}
|
||||
|
||||
MaintainerUsername = "";
|
||||
MaintainerDisplayName = "";
|
||||
MaintainerPassword = "";
|
||||
MaintainerMessage = "Maintainer has been created.";
|
||||
Maintainers = await UserService.GetMaintainersAsync();
|
||||
}
|
||||
|
||||
protected async Task DeleteMaintainer(string? id)
|
||||
{
|
||||
MaintainerMessage = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
MaintainerMessage = "Maintainer is missing an id.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await UserService.DeleteMaintainerAsync(id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
MaintainerMessage = "Maintainer could not be deleted.";
|
||||
return;
|
||||
}
|
||||
|
||||
MaintainerMessage = "Maintainer has been deleted.";
|
||||
Maintainers = await UserService.GetMaintainersAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter] private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@page "/guest"
|
||||
|
||||
<div class="auth-shell">
|
||||
<section class="card shadow-sm auth-card">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h3 mb-1">Access code</h1>
|
||||
<p class="text-secondary mb-4">Enter the guest access code to view the list.</p>
|
||||
|
||||
<form method="post" action="/guest/access">
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="access-code">Access code</label>
|
||||
<input id="access-code" class="form-control" name="accessCode" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
Continue
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0" role="alert">@ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Guestlist.Components.Pages;
|
||||
|
||||
public partial class GuestAccess
|
||||
{
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
protected string? ErrorMessage => Error switch
|
||||
{
|
||||
"invalid" => "Invalid access code.",
|
||||
"service" => "Access code validation failed. Please try again.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@page "/list"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h2 mb-1">Guest list</h1>
|
||||
<p class="text-secondary mb-0">Read-only access for guests.</p>
|
||||
</div>
|
||||
|
||||
@if (ShowLeaveGuestList)
|
||||
{
|
||||
<form method="post" action="/guest/logout">
|
||||
<button type="submit" class="btn btn-outline-secondary">
|
||||
Leave guest list
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (GuestEntries is null)
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">Loading guest list...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (GuestEntries.Count == 0)
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">No guests have been added yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="guest-card-grid">
|
||||
@foreach (var guest in GuestEntries)
|
||||
{
|
||||
<article class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">@guest.Name</h2>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(guest.Notes))
|
||||
{
|
||||
<p class="card-text">@guest.Notes</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="card-text text-secondary">No notes.</p>
|
||||
}
|
||||
|
||||
<p class="small text-secondary mb-0">
|
||||
Added by @guest.AddedByUsername<br />
|
||||
@guest.CreatedAt.ToLocalTime().ToString("g")
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Guestlist.Components.Pages;
|
||||
|
||||
public partial class GuestList : ComponentBase
|
||||
{
|
||||
[Inject]
|
||||
protected GuestEntryService GuestEntryService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected IHttpContextAccessor HttpContextAccessor { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected NavigationManager Navigation { get; set; } = default!;
|
||||
|
||||
protected List<GuestEntryDocument>? GuestEntries { get; set; }
|
||||
protected bool ShowLeaveGuestList { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authState.User;
|
||||
var isStaffUser =
|
||||
user.Identity?.IsAuthenticated == true &&
|
||||
(user.IsInRole(UserRoles.Admin) || user.IsInRole(UserRoles.Maintainer));
|
||||
|
||||
var hasGuestAccess =
|
||||
HttpContextAccessor.HttpContext?.Session.GetString("GuestAccessGranted") == "true";
|
||||
|
||||
if (!isStaffUser && !hasGuestAccess)
|
||||
{
|
||||
Navigation.NavigateTo("/guest");
|
||||
return;
|
||||
}
|
||||
|
||||
ShowLeaveGuestList = !isStaffUser && hasGuestAccess;
|
||||
GuestEntries = await GuestEntryService.GetAllAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
@page "/guests"
|
||||
@rendermode InteractiveServer
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize(Roles = "Admin,Maintainer")]
|
||||
|
||||
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h2 mb-1">Guest management</h1>
|
||||
<p class="text-secondary mb-0">Add, edit, delete and filter guest entries.</p>
|
||||
</div>
|
||||
|
||||
<div class="w-100 w-md-auto">
|
||||
<label class="form-label" for="guest-search">Search</label>
|
||||
<input id="guest-search" class="form-control" @bind="SearchTerm" @bind:event="oninput" placeholder="Name, note or user" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">@ErrorMessage</div>
|
||||
}
|
||||
|
||||
<div class="guest-card-grid">
|
||||
<article class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Add guest</h2>
|
||||
<form @onsubmit="AddGuest" @onsubmit:preventDefault="true">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="new-guest-name">Name</label>
|
||||
<input id="new-guest-name" class="form-control" @bind="NewGuestName" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="new-guest-notes">Notes</label>
|
||||
<textarea id="new-guest-notes" class="form-control" rows="3" @bind="NewGuestNotes"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
Add guest
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@if (GuestEntries is null)
|
||||
{
|
||||
<article class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">Loading guest list...</p>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
else if (GuestEntries.Count == 0)
|
||||
{
|
||||
<article class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">No guests have been added yet.</p>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
else if (!FilteredGuestEntries.Any())
|
||||
{
|
||||
<article class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">No guests match the current filter.</p>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var guest in FilteredGuestEntries)
|
||||
{
|
||||
<article class="card shadow-sm">
|
||||
<div class="card-body d-flex flex-column">
|
||||
@if (EditingGuestId == guest.Id)
|
||||
{
|
||||
<h2 class="h5 card-title">Edit guest</h2>
|
||||
|
||||
<form class="d-flex flex-column flex-grow-1" @onsubmit="SaveEdit" @onsubmit:preventDefault="true">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="EditGuestName" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-control" rows="3" @bind="EditGuestNotes"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-auto">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CancelEdit">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2 class="h5 card-title">@guest.Name</h2>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(guest.Notes))
|
||||
{
|
||||
<p class="card-text">@guest.Notes</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="card-text text-secondary">No notes.</p>
|
||||
}
|
||||
|
||||
<p class="small text-secondary mt-auto mb-3">
|
||||
Added by @guest.AddedByUsername<br />
|
||||
@guest.CreatedAt.ToLocalTime().ToString("g")
|
||||
</p>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(guest)">
|
||||
Edit
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteGuest(guest.Id)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
|
||||
namespace Guestlist.Components.Pages;
|
||||
|
||||
public partial class Guests : ComponentBase
|
||||
{
|
||||
[Inject]
|
||||
protected GuestEntryService GuestEntryService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } = default!;
|
||||
|
||||
protected string NewGuestName { get; set; } = "";
|
||||
protected string NewGuestNotes { get; set; } = "";
|
||||
protected string ErrorMessage { get; set; } = "";
|
||||
protected string SearchTerm { get; set; } = "";
|
||||
|
||||
protected string? EditingGuestId { get; set; }
|
||||
protected string EditGuestName { get; set; } = "";
|
||||
protected string EditGuestNotes { get; set; } = "";
|
||||
|
||||
protected List<GuestEntryDocument>? GuestEntries { get; set; }
|
||||
|
||||
protected IEnumerable<GuestEntryDocument> FilteredGuestEntries
|
||||
{
|
||||
get
|
||||
{
|
||||
if (GuestEntries is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SearchTerm))
|
||||
{
|
||||
return GuestEntries;
|
||||
}
|
||||
|
||||
var searchTerm = SearchTerm.Trim();
|
||||
|
||||
return GuestEntries.Where(guest =>
|
||||
guest.Name.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ||
|
||||
(guest.Notes?.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) == true) ||
|
||||
guest.AddedByUsername.Contains(searchTerm, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
GuestEntries = await GuestEntryService.GetAllAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Guest list could not be loaded.";
|
||||
GuestEntries = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task AddGuest()
|
||||
{
|
||||
ErrorMessage = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewGuestName))
|
||||
{
|
||||
ErrorMessage = "Provide a guest name.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (await GuestEntryService.NameAndNotesExistAsync(NewGuestName, NewGuestNotes))
|
||||
{
|
||||
ErrorMessage = "A guest with this name and note already exists. Use a distinct note if this is a different person.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Guest name could not be checked.";
|
||||
return;
|
||||
}
|
||||
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authState.User;
|
||||
|
||||
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
var username = user.Identity?.Name ?? "unknown";
|
||||
|
||||
try
|
||||
{
|
||||
await GuestEntryService.AddAsync(
|
||||
NewGuestName,
|
||||
NewGuestNotes,
|
||||
userId,
|
||||
username);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Guest could not be added.";
|
||||
return;
|
||||
}
|
||||
|
||||
NewGuestName = "";
|
||||
NewGuestNotes = "";
|
||||
|
||||
GuestEntries = await GuestEntryService.GetAllAsync();
|
||||
}
|
||||
|
||||
protected async Task DeleteGuest(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
ErrorMessage = "Guest entry is missing an id.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await GuestEntryService.DeleteAsync(id);
|
||||
GuestEntries = await GuestEntryService.GetAllAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Guest could not be deleted.";
|
||||
}
|
||||
}
|
||||
|
||||
protected void StartEdit(GuestEntryDocument guest)
|
||||
{
|
||||
EditingGuestId = guest.Id;
|
||||
EditGuestName = guest.Name;
|
||||
EditGuestNotes = guest.Notes ?? "";
|
||||
ErrorMessage = "";
|
||||
}
|
||||
|
||||
protected void CancelEdit()
|
||||
{
|
||||
EditingGuestId = null;
|
||||
EditGuestName = "";
|
||||
EditGuestNotes = "";
|
||||
}
|
||||
|
||||
protected async Task SaveEdit()
|
||||
{
|
||||
ErrorMessage = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EditingGuestId))
|
||||
{
|
||||
ErrorMessage = "No guest entry is selected for editing.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EditGuestName))
|
||||
{
|
||||
ErrorMessage = "Provide a guest name.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (await GuestEntryService.NameAndNotesExistAsync(EditGuestName, EditGuestNotes, EditingGuestId))
|
||||
{
|
||||
ErrorMessage = "A guest with this name and note already exists. Use a distinct note if this is a different person.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Guest name could not be checked.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await GuestEntryService.UpdateAsync(
|
||||
EditingGuestId,
|
||||
EditGuestName,
|
||||
EditGuestNotes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Guest could not be updated.";
|
||||
return;
|
||||
}
|
||||
|
||||
EditingGuestId = null;
|
||||
EditGuestName = "";
|
||||
EditGuestNotes = "";
|
||||
|
||||
GuestEntries = await GuestEntryService.GetAllAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@page "/"
|
||||
|
||||
<div class="mb-4">
|
||||
<h1 class="h2 mb-1">Guestlist</h1>
|
||||
<p class="text-secondary mb-0">A minimal guest list for maintainers and guests.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel-grid">
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Manage guests</h2>
|
||||
<p class="text-secondary">Add, edit and delete guest entries.</p>
|
||||
<a class="btn btn-primary" href="/guests">Open management</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Guest list</h2>
|
||||
<p class="text-secondary">View the read-only list. Guests are asked for an access code when needed.</p>
|
||||
<a class="btn btn-outline-primary" href="/list">Open guest list</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Admin</h2>
|
||||
<p class="text-secondary">Manage maintainers, setup and guest access.</p>
|
||||
<a class="btn btn-outline-secondary" href="/admin">Open admin</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
@page "/login"
|
||||
|
||||
<div class="auth-shell">
|
||||
<section class="card shadow-sm auth-card">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h3 mb-1">Login</h1>
|
||||
<p class="text-secondary mb-4">Sign in as admin or maintainer.</p>
|
||||
|
||||
<form method="post" action="/auth/login">
|
||||
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input id="username" class="form-control" name="username" autocomplete="username" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input id="password" class="form-control" name="password" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Guestlist.Components.Pages;
|
||||
|
||||
public partial class Login
|
||||
{
|
||||
[SupplyParameterFromQuery]
|
||||
public string? ReturnUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -0,0 +1,99 @@
|
||||
@page "/setup"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<div class="mb-4">
|
||||
<h1 class="h2 mb-1">Setup</h1>
|
||||
<p class="text-secondary mb-0">Create the first admin and initial guest access code.</p>
|
||||
</div>
|
||||
|
||||
@if (IsSetupComplete is null || HasAnyUsers is null || HasAnyUserBackups is null)
|
||||
{
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<p class="text-secondary mb-0">Loading setup status...</p>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0" role="alert">@ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else if (IsSetupComplete == true)
|
||||
{
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Setup complete</h2>
|
||||
<p class="text-secondary mb-0">Setup is already complete.</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else if (HasAnyUsers == true)
|
||||
{
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Recovery required</h2>
|
||||
<p class="text-secondary">Setup is incomplete, but an admin user already exists.</p>
|
||||
<a class="btn btn-primary" href="/login">Sign in for recovery</a>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form class="admin-panel-grid" @onsubmit="CompleteSetup" @onsubmit:preventDefault="true">
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">First admin</h2>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="setup-username">Username</label>
|
||||
<input id="setup-username" class="form-control" @bind="Username" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="setup-name">Name</label>
|
||||
<input id="setup-name" class="form-control" @bind="DisplayName" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="setup-password">Password</label>
|
||||
<input id="setup-password" class="form-control" type="password" @bind="Password" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Guest access</h2>
|
||||
<p class="text-secondary">Set the first code guests use to view the list.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="setup-access-code">Guest access code</label>
|
||||
<input id="setup-access-code" class="form-control" @bind="GuestAccessCode" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Create and finish setup
|
||||
</button>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0" role="alert">@ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (HasAnyUserBackups == true)
|
||||
{
|
||||
<section class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title">Restore backup</h2>
|
||||
<p class="text-secondary">Restore the latest backed-up users.</p>
|
||||
<button type="button" class="btn btn-outline-primary" @onclick="RestoreLatestBackup">
|
||||
Restore latest user backup
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Guestlist.Components.Pages;
|
||||
|
||||
public partial class Setup : ComponentBase
|
||||
{
|
||||
[Inject]
|
||||
protected SetupStatusService SetupStatusService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected UserService UserService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
protected AccessCodeService AccessCodeService { get; set; } = default!;
|
||||
|
||||
protected string Username { get; set; } = "";
|
||||
protected string DisplayName { get; set; } = "";
|
||||
protected string Password { get; set; } = "";
|
||||
protected string ErrorMessage { get; set; } = "";
|
||||
|
||||
protected bool? HasAnyUsers { get; set; }
|
||||
protected bool? HasAnyUserBackups { get; set; }
|
||||
|
||||
protected string GuestAccessCode { get; set; } = "";
|
||||
|
||||
protected bool? IsSetupComplete { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsSetupComplete = await SetupStatusService.IsSetupCompleteAsync();
|
||||
HasAnyUsers = await UserService.HasAnyUsersAsync();
|
||||
HasAnyUserBackups = await UserService.HasAnyUserBackupsAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Setup status could not be loaded. Please try again.";
|
||||
IsSetupComplete = false;
|
||||
HasAnyUsers = false;
|
||||
HasAnyUserBackups = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task CompleteSetup()
|
||||
{
|
||||
ErrorMessage = "";
|
||||
|
||||
if (await UserService.HasAnyUsersAsync())
|
||||
{
|
||||
ErrorMessage = "Setup cannot create another initial admin user!";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Username))
|
||||
{
|
||||
ErrorMessage = "Provide a username.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DisplayName))
|
||||
{
|
||||
DisplayName = Username;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
ErrorMessage = "Provide a password.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (Password.Length < 8)
|
||||
{
|
||||
ErrorMessage = "Password must be at least 8 characters long.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(GuestAccessCode))
|
||||
{
|
||||
ErrorMessage = "Provide a guest access code.";
|
||||
return;
|
||||
}
|
||||
|
||||
var adminCreated = false;
|
||||
|
||||
try
|
||||
{
|
||||
await UserService.CreateAdminAsync(DisplayName, Username, Password);
|
||||
adminCreated = true;
|
||||
|
||||
await AccessCodeService.SetGuestAccessCodeAsync(GuestAccessCode);
|
||||
await SetupStatusService.MarkSetupCompleteAsync();
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
ErrorMessage = exception.Message;
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (adminCreated)
|
||||
{
|
||||
await UserService.DeleteUserByUsernameAsync(Username);
|
||||
}
|
||||
|
||||
HasAnyUsers = await UserService.HasAnyUsersAsync();
|
||||
ErrorMessage = "Setup could not be completed. No partial admin user was kept.";
|
||||
return;
|
||||
}
|
||||
|
||||
GuestAccessCode = "";
|
||||
Password = "";
|
||||
HasAnyUsers = true;
|
||||
IsSetupComplete = true;
|
||||
}
|
||||
|
||||
protected async Task RestoreLatestBackup()
|
||||
{
|
||||
ErrorMessage = "";
|
||||
|
||||
if (HasAnyUsers == true)
|
||||
{
|
||||
ErrorMessage = "Cannot restore backup while users already exist.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (HasAnyUserBackups != true)
|
||||
{
|
||||
ErrorMessage = "No user backups found.";
|
||||
return;
|
||||
}
|
||||
|
||||
bool restored;
|
||||
|
||||
try
|
||||
{
|
||||
restored = await UserService.RestoreLatestUserBackupAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Could not restore latest user backup.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!restored)
|
||||
{
|
||||
ErrorMessage = "Could not restore latest user backup.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SetupStatusService.MarkSetupCompleteAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
ErrorMessage = "Backup was restored, but setup status could not be updated.";
|
||||
return;
|
||||
}
|
||||
|
||||
HasAnyUsers = true;
|
||||
IsSetupComplete = true;
|
||||
HasAnyUserBackups = await UserService.HasAnyUserBackupsAsync();
|
||||
|
||||
ErrorMessage = "Latest user backup has been restored.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,11 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using Guestlist
|
||||
@using Guestlist.Components
|
||||
@using Guestlist.Components.Layout
|
||||
@@ -0,0 +1,21 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class GuestEntryDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public string AddedByUserId { get; set; } = "";
|
||||
|
||||
public string AddedByUsername { get; set; } = "";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class GuestEntryService(MongoDbContext db)
|
||||
{
|
||||
private readonly IMongoCollection<GuestEntryDocument> _guestEntries =
|
||||
db.Database.GetCollection<GuestEntryDocument>("guest_entries");
|
||||
|
||||
public async Task<List<GuestEntryDocument>> GetAllAsync()
|
||||
{
|
||||
return await _guestEntries
|
||||
.Find(FilterDefinition<GuestEntryDocument>.Empty)
|
||||
.SortBy(x => x.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> NameExistsAsync(string name, string? excludeId = null)
|
||||
{
|
||||
var normalizedName = name.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = Builders<GuestEntryDocument>.Filter.Regex(
|
||||
x => x.Name,
|
||||
new MongoDB.Bson.BsonRegularExpression($"^{System.Text.RegularExpressions.Regex.Escape(normalizedName)}$", "i"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(excludeId))
|
||||
{
|
||||
filter &= Builders<GuestEntryDocument>.Filter.Ne(x => x.Id, excludeId);
|
||||
}
|
||||
|
||||
return await _guestEntries.CountDocumentsAsync(filter) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> NameAndNotesExistAsync(string name, string? notes, string? excludeId = null)
|
||||
{
|
||||
var normalizedName = name.Trim();
|
||||
var normalizedNotes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = Builders<GuestEntryDocument>.Filter.Regex(
|
||||
x => x.Name,
|
||||
new MongoDB.Bson.BsonRegularExpression($"^{System.Text.RegularExpressions.Regex.Escape(normalizedName)}$", "i"));
|
||||
|
||||
filter &= normalizedNotes is null
|
||||
? Builders<GuestEntryDocument>.Filter.Or(
|
||||
Builders<GuestEntryDocument>.Filter.Eq(x => x.Notes, null),
|
||||
Builders<GuestEntryDocument>.Filter.Eq(x => x.Notes, ""))
|
||||
: Builders<GuestEntryDocument>.Filter.Regex(
|
||||
x => x.Notes,
|
||||
new MongoDB.Bson.BsonRegularExpression($"^{System.Text.RegularExpressions.Regex.Escape(normalizedNotes)}$", "i"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(excludeId))
|
||||
{
|
||||
filter &= Builders<GuestEntryDocument>.Filter.Ne(x => x.Id, excludeId);
|
||||
}
|
||||
|
||||
return await _guestEntries.CountDocumentsAsync(filter) > 0;
|
||||
}
|
||||
|
||||
public async Task AddAsync(string name, string? notes, string addedByUserId, string addedByUsername)
|
||||
{
|
||||
var entry = new GuestEntryDocument
|
||||
{
|
||||
Name = name.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(),
|
||||
AddedByUserId = addedByUserId,
|
||||
AddedByUsername = addedByUsername,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _guestEntries.InsertOneAsync(entry);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string id)
|
||||
{
|
||||
await _guestEntries.DeleteOneAsync(x => x.Id == id);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(string id, string name, string? notes)
|
||||
{
|
||||
var update = Builders<GuestEntryDocument>.Update
|
||||
.Set(x => x.Name, name.Trim())
|
||||
.Set(x => x.Notes, string.IsNullOrWhiteSpace(notes) ? null : notes.Trim());
|
||||
|
||||
await _guestEntries.UpdateOneAsync(x => x.Id == id, update);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class MongoOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = "";
|
||||
public string DatabaseName { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class MongoDbContext
|
||||
{
|
||||
public IMongoDatabase Database { get; }
|
||||
|
||||
public MongoDbContext(IOptions<MongoOptions> options)
|
||||
{
|
||||
var client = new MongoClient(options.Value.ConnectionString);
|
||||
Database = client.GetDatabase(options.Value.DatabaseName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Guestlist.Components;
|
||||
using Guestlist;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.Configure<MongoOptions>(
|
||||
builder.Configuration.GetSection("Mongo"));
|
||||
|
||||
builder.Services.AddSingleton<MongoDbContext>();
|
||||
builder.Services.AddScoped<SetupStatusService>();
|
||||
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<GuestEntryService>();
|
||||
builder.Services.AddScoped<AccessCodeService>();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.AccessDeniedPath = "/login";
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
|
||||
builder.Services.AddSession(options =>
|
||||
{
|
||||
options.Cookie.Name = ".Guestlist.GuestAccess";
|
||||
options.IdleTimeout = TimeSpan.FromHours(8);
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.IsEssential = true;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapPost("/auth/login", async (
|
||||
HttpContext httpContext,
|
||||
UserService userService) =>
|
||||
{
|
||||
var form = await httpContext.Request.ReadFormAsync();
|
||||
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
var returnUrl = form["returnUrl"].ToString();
|
||||
|
||||
UserDocument? user;
|
||||
|
||||
try
|
||||
{
|
||||
user = await userService.ValidateCredentialsAsync(username, password);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Redirect("/login?error=service");
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Redirect("/login?error=invalid");
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id ?? ""),
|
||||
new(ClaimTypes.Name, user.Username),
|
||||
new(ClaimTypes.Role, user.Role)
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
claims,
|
||||
CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await httpContext.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
principal);
|
||||
|
||||
var targetUrl = IsLocalReturnUrl(returnUrl)
|
||||
? returnUrl
|
||||
: null;
|
||||
|
||||
targetUrl = string.IsNullOrWhiteSpace(targetUrl)
|
||||
? "/guests"
|
||||
: targetUrl;
|
||||
|
||||
return Results.Redirect(targetUrl);
|
||||
});
|
||||
|
||||
app.MapPost("/auth/logout", async (HttpContext httpContext) =>
|
||||
{
|
||||
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
httpContext.Session.Clear();
|
||||
|
||||
return Results.Redirect("/login");
|
||||
});
|
||||
|
||||
app.MapPost("/guest/access", async (
|
||||
HttpContext httpContext,
|
||||
AccessCodeService accessCodeService) =>
|
||||
{
|
||||
var form = await httpContext.Request.ReadFormAsync();
|
||||
var accessCode = form["accessCode"].ToString();
|
||||
|
||||
bool isValid;
|
||||
|
||||
try
|
||||
{
|
||||
isValid = await accessCodeService.ValidateGuestAccessCodeAsync(accessCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Redirect("/guest?error=service");
|
||||
}
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
return Results.Redirect("/guest?error=invalid");
|
||||
}
|
||||
|
||||
httpContext.Session.SetString("GuestAccessGranted", "true");
|
||||
|
||||
return Results.Redirect("/list");
|
||||
});
|
||||
|
||||
app.MapPost("/guest/logout", (HttpContext httpContext) =>
|
||||
{
|
||||
httpContext.Session.Remove("GuestAccessGranted");
|
||||
|
||||
return Results.Redirect("/guest");
|
||||
});
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var userService = scope.ServiceProvider.GetRequiredService<UserService>();
|
||||
await userService.EnsureIndexesAsync();
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseSession();
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path;
|
||||
|
||||
var isStaticFile = System.IO.Path.HasExtension(path.Value);
|
||||
|
||||
var allowedPaths =
|
||||
isStaticFile ||
|
||||
path.StartsWithSegments("/setup") ||
|
||||
path.StartsWithSegments("/auth/logout") ||
|
||||
path.StartsWithSegments("/guest/logout") ||
|
||||
path.StartsWithSegments("/_framework") ||
|
||||
path.StartsWithSegments("/_blazor") ||
|
||||
path.StartsWithSegments("/css") ||
|
||||
path.StartsWithSegments("/lib") ||
|
||||
path.StartsWithSegments("/favicon.png");
|
||||
|
||||
if (allowedPaths)
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = context.RequestServices.CreateScope();
|
||||
var setupStatusService = scope.ServiceProvider.GetRequiredService<SetupStatusService>();
|
||||
var userService = scope.ServiceProvider.GetRequiredService<UserService>();
|
||||
|
||||
var isSetupComplete = await setupStatusService.IsSetupCompleteAsync();
|
||||
|
||||
if (!isSetupComplete)
|
||||
{
|
||||
var hasAnyUsers = await userService.HasAnyUsersAsync();
|
||||
var allowedRecoveryPath =
|
||||
hasAnyUsers &&
|
||||
(path.StartsWithSegments("/login") ||
|
||||
path.StartsWithSegments("/auth/login") ||
|
||||
path.StartsWithSegments("/auth/logout") ||
|
||||
path.StartsWithSegments("/admin"));
|
||||
|
||||
if (allowedRecoveryPath)
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.Redirect("/setup");
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool IsLocalReturnUrl(string? returnUrl)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(returnUrl) &&
|
||||
returnUrl.StartsWith('/') &&
|
||||
!returnUrl.StartsWith("//");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5199",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7133;http://localhost:5199",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class SetupStatusService(MongoDbContext db)
|
||||
{
|
||||
private readonly IMongoCollection<AppSettingsDocument> _settings = db.Database.GetCollection<AppSettingsDocument>("app_settings");
|
||||
|
||||
public async Task<bool> IsSetupCompleteAsync()
|
||||
{
|
||||
var settings = await _settings
|
||||
.Find(x => x.Id == "global")
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return settings?.IsSetupComplete == true;
|
||||
}
|
||||
|
||||
public async Task MarkSetupCompleteAsync()
|
||||
{
|
||||
var document = new AppSettingsDocument
|
||||
{
|
||||
Id = "global",
|
||||
IsSetupComplete = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _settings.ReplaceOneAsync(
|
||||
x => x.Id == "global",
|
||||
document,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
|
||||
public async Task ResetSetupAsync()
|
||||
{
|
||||
var document = new AppSettingsDocument
|
||||
{
|
||||
Id = "global",
|
||||
IsSetupComplete = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _settings.ReplaceOneAsync(
|
||||
x => x.Id == "global",
|
||||
document,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class UserBackupDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string BackupBatchId { get; set; } = "";
|
||||
|
||||
public UserDocument User { get; set; } = new();
|
||||
|
||||
public string Reason { get; set; } = "";
|
||||
|
||||
public DateTime BackedUpAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class UserDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
public string PasswordHash { get; set; } = "";
|
||||
|
||||
public string Role { get; set; } = UserRoles.Admin;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Guestlist;
|
||||
|
||||
public static class UserRoles
|
||||
{
|
||||
public const string Admin = "Admin";
|
||||
public const string Maintainer = "Maintainer";
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Guestlist;
|
||||
|
||||
public sealed class UserService(MongoDbContext db)
|
||||
{
|
||||
private readonly IMongoCollection<UserDocument> _users = db.Database.GetCollection<UserDocument>("users");
|
||||
private readonly PasswordHasher<UserDocument> _passwordHasher = new();
|
||||
|
||||
private readonly IMongoCollection<UserBackupDocument> _userBackups = db.Database.GetCollection<UserBackupDocument>("user_backups");
|
||||
|
||||
public Task CreateAdminAsync(string displayName, string username, string password)
|
||||
{
|
||||
return CreateUserAsync(displayName, username, password, UserRoles.Admin);
|
||||
}
|
||||
|
||||
public Task CreateMaintainerAsync(string displayName, string username, string password)
|
||||
{
|
||||
return CreateUserAsync(displayName, username, password, UserRoles.Maintainer);
|
||||
}
|
||||
|
||||
public async Task<List<UserDocument>> GetMaintainersAsync()
|
||||
{
|
||||
return await _users
|
||||
.Find(x => x.Role == UserRoles.Maintainer)
|
||||
.SortBy(x => x.Username)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteMaintainerAsync(string id)
|
||||
{
|
||||
await _users.DeleteOneAsync(x => x.Id == id && x.Role == UserRoles.Maintainer);
|
||||
}
|
||||
|
||||
public async Task DeleteUserByUsernameAsync(string username)
|
||||
{
|
||||
var normalizedUsername = username.Trim().ToLowerInvariant();
|
||||
await _users.DeleteOneAsync(x => x.Username == normalizedUsername);
|
||||
}
|
||||
|
||||
private async Task CreateUserAsync(string displayName, string username, string password, string role)
|
||||
{
|
||||
var normalizedUsername = username.Trim().ToLowerInvariant();
|
||||
|
||||
if (await UsernameExistsAsync(normalizedUsername))
|
||||
{
|
||||
throw new InvalidOperationException("Username already exists.");
|
||||
}
|
||||
|
||||
var user = new UserDocument
|
||||
{
|
||||
DisplayName = displayName.Trim(),
|
||||
Username = normalizedUsername,
|
||||
Role = role,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, password);
|
||||
|
||||
await _users.InsertOneAsync(user);
|
||||
}
|
||||
|
||||
public async Task<bool> HasAnyUsersAsync()
|
||||
=> await _users.CountDocumentsAsync(FilterDefinition<UserDocument>.Empty) > 0;
|
||||
|
||||
public async Task BackupAndClearUsersAsync(string reason)
|
||||
{
|
||||
var users = await _users
|
||||
.Find(FilterDefinition<UserDocument>.Empty)
|
||||
.ToListAsync();
|
||||
|
||||
var backupBatchId = Guid.NewGuid().ToString("N");
|
||||
var backedUpAt = DateTime.UtcNow;
|
||||
|
||||
if (users.Count > 0)
|
||||
{
|
||||
var backups = users.Select(user => new UserBackupDocument
|
||||
{
|
||||
BackupBatchId = backupBatchId,
|
||||
User = user,
|
||||
Reason = reason,
|
||||
BackedUpAt = backedUpAt
|
||||
});
|
||||
|
||||
await _userBackups.InsertManyAsync(backups);
|
||||
}
|
||||
|
||||
await _users.DeleteManyAsync(FilterDefinition<UserDocument>.Empty);
|
||||
}
|
||||
|
||||
public async Task<bool> HasAnyUserBackupsAsync()
|
||||
{
|
||||
var count = await _userBackups.CountDocumentsAsync(
|
||||
FilterDefinition<UserBackupDocument>.Empty);
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> RestoreLatestUserBackupAsync()
|
||||
{
|
||||
var latestBackup = await _userBackups
|
||||
.Find(x => x.BackupBatchId != "")
|
||||
.SortByDescending(x => x.BackedUpAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (latestBackup is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var existingUsersCount = await _users.CountDocumentsAsync(
|
||||
FilterDefinition<UserDocument>.Empty);
|
||||
|
||||
if (existingUsersCount > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var backupBatchId = latestBackup.BackupBatchId;
|
||||
|
||||
var backups = await _userBackups
|
||||
.Find(x => x.BackupBatchId == backupBatchId)
|
||||
.ToListAsync();
|
||||
|
||||
if (backups.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var users = backups.Select(backup =>
|
||||
{
|
||||
var user = backup.User;
|
||||
user.Id = null;
|
||||
return user;
|
||||
}).ToList();
|
||||
|
||||
await _users.InsertManyAsync(users);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UsernameExistsAsync(string username)
|
||||
{
|
||||
var normalizedUsername = username.Trim().ToLowerInvariant();
|
||||
return await _users.CountDocumentsAsync(x => x.Username == normalizedUsername) > 0;
|
||||
}
|
||||
|
||||
public async Task EnsureIndexesAsync()
|
||||
{
|
||||
var usernameIndex = new CreateIndexModel<UserDocument>(
|
||||
Builders<UserDocument>.IndexKeys.Ascending(x => x.Username),
|
||||
new CreateIndexOptions { Unique = true });
|
||||
|
||||
await _users.Indexes.CreateOneAsync(usernameIndex);
|
||||
}
|
||||
|
||||
public async Task<UserDocument?> ValidateCredentialsAsync(string username, string password)
|
||||
{
|
||||
var normalizedUsername = username.Trim().ToLowerInvariant();
|
||||
|
||||
var user = await _users
|
||||
.Find(x => x.Username == normalizedUsername && x.IsActive)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = _passwordHasher.VerifyHashedPassword(
|
||||
user,
|
||||
user.PasswordHash,
|
||||
password);
|
||||
|
||||
return result == PasswordVerificationResult.Success ? user : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Mongo": {
|
||||
"ConnectionString": "mongodb://localhost:27017",
|
||||
"DatabaseName": "guestlist"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background: #f6f7f9;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #006bb7;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
.guest-card-grid,
|
||||
.admin-panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.guest-card-grid > .card,
|
||||
.admin-panel-grid > .card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.guest-card-grid .card-body {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
min-height: calc(100vh - 160px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(100%, 420px);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.w-md-auto {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.w-md-auto {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,597 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: left;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* rtl:raw:
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,594 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: right;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: right;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12057
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12030
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4494
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user