Initial build: WishNinja self-hosted gift wishlist manager
ASP.NET Core Blazor (.NET 10) + EF Core/SQLite + Identity. Features: - Wishlists & items with local image storage (upload, clipboard paste, or URL fetched and stored locally) - Owner-hidden claims (enforced at the query layer) to preserve surprises - Admin-invite-only onboarding with email-based password resets - All state under /data; ships as a single Docker image Includes Dockerfile, docker-compose, Gitea Actions CI (test + push image), Unraid template, and xUnit tests (claim privacy, invite lifecycle, image validation). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/Data/
|
||||
**/smoke-data/
|
||||
**/data/
|
||||
**/.vs/
|
||||
**/.git/
|
||||
**/*.user
|
||||
tests/
|
||||
README.md
|
||||
docker-compose.yml
|
||||
unraid-template.xml
|
||||
@@ -0,0 +1,13 @@
|
||||
# Normalize line endings: store text as LF in the repo so the Linux Docker build
|
||||
# and Gitea CI runners get consistent files regardless of who commits from Windows.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Treat common binary assets as binary (never normalized).
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Build & Push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
- name: Build
|
||||
run: dotnet build -c Release
|
||||
- name: Test
|
||||
run: dotnet test -c Release --no-build
|
||||
|
||||
docker:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Registry host defaults to this Gitea instance; the image is named after the repo.
|
||||
- name: Resolve image metadata
|
||||
id: meta
|
||||
run: |
|
||||
REGISTRY="${GITHUB_SERVER_URL#https://}"
|
||||
REGISTRY="${REGISTRY#http://}"
|
||||
echo "registry=${REGISTRY}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.meta.outputs.registry }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.tag }}
|
||||
${{ steps.meta.outputs.image }}:${{ github.sha }}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
## .NET build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
|
||||
## Local runtime data (db, uploads, data-protection keys)
|
||||
data/
|
||||
**/Data/*.db
|
||||
**/Data/*.db-shm
|
||||
**/Data/*.db-wal
|
||||
src/WishNinja/smoke-data/
|
||||
src/WishNinja/Data/
|
||||
|
||||
## IDE
|
||||
.vs/
|
||||
.idea/
|
||||
*.suo
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# ---- Build stage: compile and publish with the full SDK ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Restore first (cached unless the project file changes).
|
||||
COPY src/WishNinja/WishNinja.csproj src/WishNinja/
|
||||
RUN dotnet restore src/WishNinja/WishNinja.csproj
|
||||
|
||||
# Build + publish.
|
||||
COPY src/ src/
|
||||
RUN dotnet publish src/WishNinja/WishNinja.csproj -c Release -o /app /p:UseAppHost=false
|
||||
|
||||
# ---- Runtime stage: ship only the ASP.NET Core runtime ----
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Listen on 8080 (no TLS — terminate TLS at your reverse proxy) and keep all
|
||||
# mutable state under /data so a single volume persists db, uploads and keys.
|
||||
ENV ASPNETCORE_HTTP_PORTS=8080 \
|
||||
WishNinja__DataPath=/data
|
||||
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
|
||||
COPY --from=build /app .
|
||||
ENTRYPOINT ["dotnet", "WishNinja.dll"]
|
||||
@@ -0,0 +1,107 @@
|
||||
# 🥷 WishNinja
|
||||
|
||||
A self-hosted **gift wishlist manager** for a closed group (family/friends). Create wishlists,
|
||||
add items with links, prices and images, and let others **claim gifts so the owner never sees what's
|
||||
been claimed** — preserving the surprise while preventing duplicate gifts.
|
||||
|
||||
Built with **ASP.NET Core Blazor (.NET 10)**, **EF Core + SQLite**, and **ASP.NET Core Identity**.
|
||||
Ships as a single Docker image; all state lives in one mounted volume.
|
||||
|
||||
## Features
|
||||
|
||||
- **Wishlists & items** — title/description, per-item link, price, priority, quantity and image.
|
||||
- **Three ways to add an image** — upload a file, fetch from an image URL, or just `Ctrl`+`V` a
|
||||
copied image. **All images are stored locally** on the data volume; pasted URLs are downloaded
|
||||
server-side and saved as files, so a wishlist never breaks when the source URL goes away.
|
||||
- **Owner-hidden claims** — when someone claims/reserves an item, the list owner can never see it.
|
||||
Other viewers see claims (and remaining quantity) to avoid double-gifting. Enforced at the query
|
||||
layer, not just hidden in the UI.
|
||||
- **Sharing** — a list is visible to all members or only to specific people you choose.
|
||||
- **Invite-only accounts** — no open registration. Admins invite by email; the recipient follows a
|
||||
single-use link to set their display name + password. Password resets via email.
|
||||
- **Admin area** — manage users (promote/disable) and invitations.
|
||||
|
||||
## Quick start (Docker Compose)
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open <http://localhost:8080>, and sign in with the bootstrap admin credentials from
|
||||
`docker-compose.yml` (`SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD`). Change these before first run.
|
||||
|
||||
> If SMTP isn't configured, emails aren't sent — but invite links are still shown directly in the
|
||||
> admin **Invitations** page, so you can onboard users without an email server.
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings are environment variables (double-underscore maps to nested config).
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `WishNinja__DataPath` | Directory for the SQLite db, uploads and keys | `/data` (in container) |
|
||||
| `WishNinja__BaseUrl` | Public URL, used in email links | `https://localhost:7777` |
|
||||
| `SEED_ADMIN_EMAIL` | First-run bootstrap admin email | — |
|
||||
| `SEED_ADMIN_PASSWORD` | First-run bootstrap admin password | — |
|
||||
| `WishNinja__Smtp__Host` | SMTP server (empty ⇒ emails logged, not sent) | empty |
|
||||
| `WishNinja__Smtp__Port` | SMTP port | `587` |
|
||||
| `WishNinja__Smtp__UseStartTls` | Use STARTTLS | `true` |
|
||||
| `WishNinja__Smtp__User` / `__Password` | SMTP credentials | empty |
|
||||
| `WishNinja__Smtp__FromAddress` / `__FromName` | Sender identity | — |
|
||||
| `WishNinja__Uploads__MaxBytes` | Max upload size in bytes | `5242880` (5 MB) |
|
||||
| `WishNinja__Invites__ExpiryHours` | Invite link lifetime | `168` (7 days) |
|
||||
| `ConnectionStrings__DefaultConnection` | Override the SQLite connection string | derived from `DataPath` |
|
||||
|
||||
The container listens on **port 8080** over plain HTTP — terminate TLS at your reverse proxy.
|
||||
|
||||
## Running on Unraid
|
||||
|
||||
1. **Build & publish the image.** Push to your Gitea repo; the included
|
||||
[`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) runs the tests and pushes
|
||||
`your-gitea-host/youruser/wishninja:latest` to Gitea's built-in container registry. (Or build
|
||||
locally and push manually.)
|
||||
2. **Add the container** (Docker tab → *Add Container*, or import
|
||||
[`unraid-template.xml`](unraid-template.xml)):
|
||||
- **Repository:** `your-gitea-host/youruser/wishninja:latest`
|
||||
- **Port:** host `8080` → container `8080`
|
||||
- **Path:** host `/mnt/user/appdata/wishninja` → container `/data`
|
||||
- **Env vars:** at minimum `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `WishNinja__BaseUrl`,
|
||||
and SMTP settings.
|
||||
3. **Reverse proxy (SWAG / Nginx Proxy Manager).** Blazor's interactive server mode uses **SignalR
|
||||
over WebSockets**, so the proxy **must have WebSocket support enabled** (NPM: the "Websockets
|
||||
Support" toggle; SWAG: included in the default proxy-conf samples).
|
||||
|
||||
Everything (database, uploaded images, data-protection keys) lives under `/data`, so backing up
|
||||
`/mnt/user/appdata/wishninja` backs up the whole app, and container updates preserve all state.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run locally (uses ./Data for the SQLite db by default)
|
||||
cd src/WishNinja
|
||||
SEED_ADMIN_EMAIL=admin@local SEED_ADMIN_PASSWORD='Admin!2345' dotnet run
|
||||
|
||||
# Run the tests
|
||||
dotnet test
|
||||
```
|
||||
|
||||
EF Core migrations are applied automatically on startup. To add a migration after changing
|
||||
entities:
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add <Name> --project src/WishNinja/WishNinja.csproj --output-dir Data/Migrations
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/WishNinja/ ASP.NET Core Blazor Web App
|
||||
Data/ EF Core context, entities, migrations
|
||||
Services/ EmailSender, InviteService, ImageService, WishlistService, seeding
|
||||
Components/ Razor components (Pages, Account, Wishlists, Layout)
|
||||
wwwroot/js/ clipboard-paste interop
|
||||
tests/WishNinja.Tests/ xUnit tests (claim privacy, invite lifecycle, image validation)
|
||||
Dockerfile multi-stage build → aspnet:10.0 runtime
|
||||
docker-compose.yml local + Unraid reference
|
||||
.gitea/workflows/ CI: test + build/push image
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/WishNinja/WishNinja.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/WishNinja.Tests/WishNinja.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
wishninja:
|
||||
build: .
|
||||
# For Unraid, replace `build: .` with your published image, e.g.:
|
||||
# image: gitea.example.com/youruser/wishninja:latest
|
||||
container_name: wishninja
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# SQLite db, uploaded images and data-protection keys all live here.
|
||||
- ./data:/data
|
||||
environment:
|
||||
# Public URL — used to build links in invite / password-reset emails.
|
||||
WishNinja__BaseUrl: "http://localhost:8080"
|
||||
|
||||
# First-run bootstrap admin (only used if the account doesn't already exist).
|
||||
SEED_ADMIN_EMAIL: "[email protected]"
|
||||
SEED_ADMIN_PASSWORD: "ChangeMe!2345"
|
||||
|
||||
# SMTP for password-reset and invite emails. Leave Host empty to log emails
|
||||
# instead of sending them (invite links are also shown in the admin UI).
|
||||
WishNinja__Smtp__Host: ""
|
||||
WishNinja__Smtp__Port: "587"
|
||||
WishNinja__Smtp__UseStartTls: "true"
|
||||
WishNinja__Smtp__User: ""
|
||||
WishNinja__Smtp__Password: ""
|
||||
WishNinja__Smtp__FromAddress: "[email protected]"
|
||||
WishNinja__Smtp__FromName: "WishNinja"
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using WishNinja.Components.Account.Pages;
|
||||
using WishNinja.Components.Account.Pages.Manage;
|
||||
using WishNinja.Data;
|
||||
|
||||
namespace Microsoft.AspNetCore.Routing;
|
||||
|
||||
internal static class IdentityComponentsEndpointRouteBuilderExtensions
|
||||
{
|
||||
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
|
||||
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var accountGroup = endpoints.MapGroup("/Account");
|
||||
|
||||
accountGroup.MapPost("/PerformExternalLogin", (
|
||||
HttpContext context,
|
||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||
[FromForm] string provider,
|
||||
[FromForm] string returnUrl) =>
|
||||
{
|
||||
IEnumerable<KeyValuePair<string, StringValues>> query = [
|
||||
new("ReturnUrl", returnUrl),
|
||||
new("Action", ExternalLogin.LoginCallbackAction)];
|
||||
|
||||
var redirectUrl = UriHelper.BuildRelative(
|
||||
context.Request.PathBase,
|
||||
"/Account/ExternalLogin",
|
||||
QueryString.Create(query));
|
||||
|
||||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
|
||||
return TypedResults.Challenge(properties, [provider]);
|
||||
});
|
||||
|
||||
accountGroup.MapPost("/Logout", async (
|
||||
ClaimsPrincipal user,
|
||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||
[FromForm] string returnUrl) =>
|
||||
{
|
||||
await signInManager.SignOutAsync();
|
||||
return TypedResults.LocalRedirect($"~/{returnUrl}");
|
||||
});
|
||||
|
||||
accountGroup.MapPost("/PasskeyCreationOptions", async (
|
||||
HttpContext context,
|
||||
[FromServices] UserManager<ApplicationUser> userManager,
|
||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||
[FromServices] IAntiforgery antiforgery) =>
|
||||
{
|
||||
await antiforgery.ValidateRequestAsync(context);
|
||||
|
||||
var user = await userManager.GetUserAsync(context.User);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
|
||||
}
|
||||
|
||||
var userId = await userManager.GetUserIdAsync(user);
|
||||
var userName = await userManager.GetUserNameAsync(user) ?? "User";
|
||||
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
|
||||
{
|
||||
Id = userId,
|
||||
Name = userName,
|
||||
DisplayName = userName
|
||||
});
|
||||
return TypedResults.Content(optionsJson, contentType: "application/json");
|
||||
});
|
||||
|
||||
accountGroup.MapPost("/PasskeyRequestOptions", async (
|
||||
HttpContext context,
|
||||
[FromServices] UserManager<ApplicationUser> userManager,
|
||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||
[FromServices] IAntiforgery antiforgery,
|
||||
[FromQuery] string? username) =>
|
||||
{
|
||||
await antiforgery.ValidateRequestAsync(context);
|
||||
|
||||
var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
|
||||
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
|
||||
return TypedResults.Content(optionsJson, contentType: "application/json");
|
||||
});
|
||||
|
||||
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();
|
||||
|
||||
manageGroup.MapPost("/LinkExternalLogin", async (
|
||||
HttpContext context,
|
||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||
[FromForm] string provider) =>
|
||||
{
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await context.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
|
||||
var redirectUrl = UriHelper.BuildRelative(
|
||||
context.Request.PathBase,
|
||||
"/Account/Manage/ExternalLogins",
|
||||
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));
|
||||
|
||||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User));
|
||||
return TypedResults.Challenge(properties, [provider]);
|
||||
});
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");
|
||||
|
||||
manageGroup.MapPost("/DownloadPersonalData", async (
|
||||
HttpContext context,
|
||||
[FromServices] UserManager<ApplicationUser> userManager,
|
||||
[FromServices] AuthenticationStateProvider authenticationStateProvider) =>
|
||||
{
|
||||
var user = await userManager.GetUserAsync(context.User);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
|
||||
}
|
||||
|
||||
var userId = await userManager.GetUserIdAsync(user);
|
||||
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);
|
||||
|
||||
// Only include personal data for download
|
||||
var personalData = new Dictionary<string, string>();
|
||||
var personalDataProps = typeof(ApplicationUser).GetProperties().Where(
|
||||
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
|
||||
foreach (var p in personalDataProps)
|
||||
{
|
||||
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
|
||||
}
|
||||
|
||||
var logins = await userManager.GetLoginsAsync(user);
|
||||
foreach (var l in logins)
|
||||
{
|
||||
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
|
||||
}
|
||||
|
||||
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
|
||||
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);
|
||||
|
||||
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
|
||||
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
|
||||
});
|
||||
|
||||
return accountGroup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
||||
using WishNinja.Data;
|
||||
|
||||
namespace WishNinja.Components.Account;
|
||||
|
||||
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
|
||||
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
|
||||
{
|
||||
private readonly IEmailSender emailSender = new NoOpEmailSender();
|
||||
|
||||
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
|
||||
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
|
||||
|
||||
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
|
||||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>.");
|
||||
|
||||
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
|
||||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using WishNinja.Data;
|
||||
|
||||
namespace WishNinja.Components.Account;
|
||||
|
||||
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
|
||||
{
|
||||
public const string StatusCookieName = "Identity.StatusMessage";
|
||||
|
||||
private static readonly CookieBuilder StatusCookieBuilder = new()
|
||||
{
|
||||
SameSite = SameSiteMode.Strict,
|
||||
HttpOnly = true,
|
||||
IsEssential = true,
|
||||
MaxAge = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
public void RedirectTo(string? uri)
|
||||
{
|
||||
uri ??= "";
|
||||
|
||||
// Prevent open redirects.
|
||||
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
|
||||
{
|
||||
uri = navigationManager.ToBaseRelativePath(uri);
|
||||
}
|
||||
|
||||
navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
|
||||
{
|
||||
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
|
||||
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
|
||||
RedirectTo(newUri);
|
||||
}
|
||||
|
||||
public void RedirectToWithStatus(string uri, string message, HttpContext context)
|
||||
{
|
||||
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
|
||||
RedirectTo(uri);
|
||||
}
|
||||
|
||||
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
|
||||
|
||||
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
|
||||
|
||||
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
|
||||
=> RedirectToWithStatus(CurrentPath, message, context);
|
||||
|
||||
public void RedirectToInvalidUser(UserManager<ApplicationUser> userManager, HttpContext context)
|
||||
=> RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Server;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WishNinja.Data;
|
||||
|
||||
namespace WishNinja.Components.Account;
|
||||
|
||||
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
|
||||
// every 30 minutes an interactive circuit is connected.
|
||||
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<IdentityOptions> options)
|
||||
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
|
||||
{
|
||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||
|
||||
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
||||
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get the user manager from a new scope to ensure it fetches fresh data
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(principal);
|
||||
if (user is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else if (!userManager.SupportsUserSecurityStamp)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
|
||||
var userStamp = await userManager.GetSecurityStampAsync(user);
|
||||
return principalStamp == userStamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@page "/Account/accept-invite"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
@using WishNinja.Services
|
||||
|
||||
@inject InviteService InviteService
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Accept invitation</PageTitle>
|
||||
|
||||
<h1>Set up your account</h1>
|
||||
|
||||
@if (invite is null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
This invite link is invalid or has expired. Please ask an administrator for a new invitation.
|
||||
</div>
|
||||
<p><a href="Account/Login">Back to sign in</a></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>You're joining as <strong>@invite.Email</strong>. Choose a display name and password.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<StatusMessage Message="@Message" />
|
||||
<EditForm Model="Input" FormName="accept-invite" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
|
||||
<input type="hidden" name="Input.Token" value="@Input.Token" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.DisplayName" id="Input.DisplayName" class="form-control" autocomplete="name" placeholder="Your name" />
|
||||
<label for="Input.DisplayName">Display name</label>
|
||||
<ValidationMessage For="() => Input.DisplayName" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" id="Input.Password" class="form-control" autocomplete="new-password" placeholder="Password" />
|
||||
<label for="Input.Password">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Confirm password" />
|
||||
<label for="Input.ConfirmPassword">Confirm password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Create account</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private Data.Entities.Invite? invite;
|
||||
private IEnumerable<IdentityError>? identityErrors;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery(Name = "token")]
|
||||
private string? Token { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(e => e.Description))}";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
// On first GET the token is in the query; on post it round-trips via the hidden field.
|
||||
var rawToken = !string.IsNullOrEmpty(Input.Token) ? Input.Token : Token;
|
||||
Input.Token = rawToken ?? "";
|
||||
invite = await InviteService.GetValidInviteAsync(Input.Token);
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var result = await InviteService.AcceptInviteAsync(Input.Token, Input.DisplayName, Input.Password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Login", "Account created — please sign in.", HttpContext);
|
||||
return;
|
||||
}
|
||||
identityErrors = result.Errors;
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
public string Token { get; set; } = "";
|
||||
|
||||
[Required, StringLength(100, MinimumLength = 2)]
|
||||
[Display(Name = "Display name")]
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Confirm password")]
|
||||
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
|
||||
public string ConfirmPassword { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/Account/AccessDenied"
|
||||
|
||||
<PageTitle>Access denied</PageTitle>
|
||||
|
||||
<header>
|
||||
<h1 class="text-danger">Access denied</h1>
|
||||
<p class="text-danger">You do not have access to this resource.</p>
|
||||
</header>
|
||||
@@ -0,0 +1,49 @@
|
||||
@page "/Account/ConfirmEmail"
|
||||
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Confirm email</PageTitle>
|
||||
|
||||
<h1>Confirm email</h1>
|
||||
<StatusMessage Message="@statusMessage" />
|
||||
|
||||
@code {
|
||||
private string? statusMessage;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? UserId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Code { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (UserId is null || Code is null)
|
||||
{
|
||||
RedirectManager.RedirectTo("");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await UserManager.FindByIdAsync(UserId);
|
||||
if (user is null)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
statusMessage = $"Error loading user with ID {UserId}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
|
||||
var result = await UserManager.ConfirmEmailAsync(user, code);
|
||||
statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@page "/Account/ConfirmEmailChange"
|
||||
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Confirm email change</PageTitle>
|
||||
|
||||
<h1>Confirm email change</h1>
|
||||
|
||||
<StatusMessage Message="@message" />
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? UserId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Email { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Code { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (UserId is null || Email is null || Code is null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus(
|
||||
"Account/Login", "Error: Invalid email change confirmation link.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await UserManager.FindByIdAsync(UserId);
|
||||
if (user is null)
|
||||
{
|
||||
message = "Unable to find user with Id '{userId}'";
|
||||
return;
|
||||
}
|
||||
|
||||
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
|
||||
var result = await UserManager.ChangeEmailAsync(user, Email, code);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
message = "Error changing email.";
|
||||
return;
|
||||
}
|
||||
|
||||
// In our UI email and user name are one and the same, so when we update the email
|
||||
// we need to update the user name.
|
||||
var setUserNameResult = await UserManager.SetUserNameAsync(user, Email);
|
||||
if (!setUserNameResult.Succeeded)
|
||||
{
|
||||
message = "Error changing user name.";
|
||||
return;
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
message = "Thank you for confirming your email change.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
@page "/Account/ExternalLogin"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Security.Claims
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IUserStore<ApplicationUser> UserStore
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<ExternalLogin> Logger
|
||||
|
||||
<PageTitle>Register</PageTitle>
|
||||
|
||||
<StatusMessage Message="@message" />
|
||||
<h1>Register</h1>
|
||||
<h2>Associate your @ProviderDisplayName account.</h2>
|
||||
<hr />
|
||||
|
||||
<div class="alert alert-info">
|
||||
You've successfully authenticated with <strong>@ProviderDisplayName</strong>.
|
||||
Please enter an email address for this site below and click the Register button to finish
|
||||
logging in.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" OnValidSubmit="OnValidSubmitAsync" FormName="confirmation" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email." />
|
||||
<label for="Input.Email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
public const string LoginCallbackAction = "LoginCallback";
|
||||
|
||||
private string? message;
|
||||
private ExternalLoginInfo? externalLoginInfo;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? RemoteError { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Action { get; set; }
|
||||
|
||||
private string? ProviderDisplayName => externalLoginInfo?.ProviderDisplayName;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
if (RemoteError is not null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var info = await SignInManager.GetExternalLoginInfoAsync();
|
||||
if (info is null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
externalLoginInfo = info;
|
||||
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method))
|
||||
{
|
||||
if (Action == LoginCallbackAction)
|
||||
{
|
||||
await OnLoginCallbackAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// We should only reach this page via the login callback, so redirect back to
|
||||
// the login page if we get here some other way.
|
||||
RedirectManager.RedirectTo("Account/Login");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnLoginCallbackAsync()
|
||||
{
|
||||
if (externalLoginInfo is null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign in the user with this external login provider if the user already has a login.
|
||||
var result = await SignInManager.ExternalLoginSignInAsync(
|
||||
externalLoginInfo.LoginProvider,
|
||||
externalLoginInfo.ProviderKey,
|
||||
isPersistent: false,
|
||||
bypassTwoFactor: true);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation(
|
||||
"{Name} logged in with {LoginProvider} provider.",
|
||||
externalLoginInfo.Principal.Identity?.Name,
|
||||
externalLoginInfo.LoginProvider);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
return;
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user does not have an account, then ask the user to create an account.
|
||||
if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
|
||||
{
|
||||
Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (externalLoginInfo is null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information during confirmation.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var emailStore = GetEmailStore();
|
||||
var user = CreateUser();
|
||||
|
||||
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
|
||||
await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
|
||||
|
||||
var result = await UserManager.CreateAsync(user);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
result = await UserManager.AddLoginAsync(user, externalLoginInfo);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider);
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
|
||||
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
// If account confirmation is required, we need to show the link if we don't have a real email sender
|
||||
if (UserManager.Options.SignIn.RequireConfirmedAccount)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email });
|
||||
}
|
||||
else
|
||||
{
|
||||
await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}";
|
||||
}
|
||||
}
|
||||
|
||||
private ApplicationUser CreateUser()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Activator.CreateInstance<ApplicationUser>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
|
||||
$"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
|
||||
}
|
||||
}
|
||||
|
||||
private IUserEmailStore<ApplicationUser> GetEmailStore()
|
||||
{
|
||||
if (!UserManager.SupportsUserEmail)
|
||||
{
|
||||
throw new NotSupportedException("The default UI requires a user store with email support.");
|
||||
}
|
||||
return (IUserEmailStore<ApplicationUser>)UserStore;
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@page "/Account/ForgotPassword"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Forgot your password?</PageTitle>
|
||||
|
||||
<h1>Forgot your password?</h1>
|
||||
<h2>Enter your email.</h2>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" FormName="forgot-password" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="[email protected]" />
|
||||
<label for="Input.Email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset password</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Input ??= new();
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var user = await UserManager.FindByEmailAsync(Input.Email);
|
||||
if (user is null || !(await UserManager.IsEmailConfirmedAsync(user)))
|
||||
{
|
||||
// Don't reveal that the user does not exist or is not confirmed
|
||||
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
// For more information on how to enable account confirmation and password reset please
|
||||
// visit https://go.microsoft.com/fwlink/?LinkID=532713
|
||||
var code = await UserManager.GeneratePasswordResetTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["code"] = code });
|
||||
|
||||
await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/Account/ForgotPasswordConfirmation"
|
||||
|
||||
<PageTitle>Forgot password confirmation</PageTitle>
|
||||
|
||||
<h1>Forgot password confirmation</h1>
|
||||
<p role="alert">
|
||||
Please check your email to reset your password.
|
||||
</p>
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/Account/InvalidPasswordReset"
|
||||
|
||||
<PageTitle>Invalid password reset</PageTitle>
|
||||
|
||||
<h1>Invalid password reset</h1>
|
||||
<p role="alert">
|
||||
The password reset link is invalid.
|
||||
</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
@page "/Account/InvalidUser"
|
||||
|
||||
<PageTitle>Invalid user</PageTitle>
|
||||
|
||||
<h3>Invalid user</h3>
|
||||
|
||||
<StatusMessage />
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/Account/Lockout"
|
||||
|
||||
<PageTitle>Locked out</PageTitle>
|
||||
|
||||
<header>
|
||||
<h1 class="text-danger">Locked out</h1>
|
||||
<p class="text-danger" role="alert">This account has been locked out, please try again later.</p>
|
||||
</header>
|
||||
@@ -0,0 +1,154 @@
|
||||
@page "/Account/Login"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject ILogger<Login> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Log in</PageTitle>
|
||||
|
||||
<h1>Log in</h1>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<section>
|
||||
<StatusMessage Message="@errorMessage" />
|
||||
<EditForm EditContext="editContext" method="post" OnSubmit="LoginUser" FormName="login">
|
||||
<DataAnnotationsValidator />
|
||||
<h2>Use a local account to log in.</h2>
|
||||
<hr />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" autocomplete="username webauthn" aria-required="true" placeholder="[email protected]" />
|
||||
<label for="Input.Email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" id="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password" />
|
||||
<label for="Input.Password" class="form-label">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
<div class="checkbox mb-3">
|
||||
<label class="form-label">
|
||||
<InputCheckbox @bind-Value="Input.RememberMe" class="darker-border-checkbox form-check-input" />
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-secondary mx-auto mt-2">OR</span>
|
||||
<PasskeySubmit Operation="PasskeyOperation.Request" Name="Input.Passkey" EmailName="Input.Email" class="btn btn-link mx-auto">Log in with a passkey</PasskeySubmit>
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<p>
|
||||
<a href="Account/ForgotPassword">Forgot your password?</a>
|
||||
</p>
|
||||
<p class="text-secondary">
|
||||
WishNinja is invite-only. Need an account? Ask an administrator for an invitation.
|
||||
</p>
|
||||
</div>
|
||||
</EditForm>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? errorMessage;
|
||||
private EditContext editContext = default!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
editContext = new EditContext(Input);
|
||||
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method))
|
||||
{
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoginUser()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Input.Passkey?.Error))
|
||||
{
|
||||
errorMessage = $"Error: {Input.Passkey.Error}";
|
||||
return;
|
||||
}
|
||||
|
||||
SignInResult result;
|
||||
if (!string.IsNullOrEmpty(Input.Passkey?.CredentialJson))
|
||||
{
|
||||
// When performing passkey sign-in, don't perform form validation.
|
||||
result = await SignInManager.PasskeySignInAsync(Input.Passkey.CredentialJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If doing a password sign-in, validate the form.
|
||||
if (!editContext.Validate())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// This doesn't count login failures towards account lockout
|
||||
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
|
||||
result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
|
||||
}
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User logged in.");
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.RequiresTwoFactor)
|
||||
{
|
||||
RedirectManager.RedirectTo(
|
||||
"Account/LoginWith2fa",
|
||||
new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User account locked out.");
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Error: Invalid login attempt.";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Display(Name = "Remember me?")]
|
||||
public bool RememberMe { get; set; }
|
||||
|
||||
public PasskeyInputModel? Passkey { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@page "/Account/LoginWith2fa"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<LoginWith2fa> Logger
|
||||
|
||||
<PageTitle>Two-factor authentication</PageTitle>
|
||||
|
||||
<h1>Two-factor authentication</h1>
|
||||
<hr />
|
||||
<StatusMessage Message="@message" />
|
||||
<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" FormName="login-with-2fa" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<input type="hidden" name="ReturnUrl" value="@ReturnUrl" />
|
||||
<input type="hidden" name="RememberMe" value="@RememberMe" />
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.TwoFactorCode" id="Input.TwoFactorCode" class="form-control" autocomplete="off" />
|
||||
<label for="Input.TwoFactorCode" class="form-label">Authenticator code</label>
|
||||
<ValidationMessage For="() => Input.TwoFactorCode" class="text-danger" />
|
||||
</div>
|
||||
<div class="checkbox mb-3">
|
||||
<label for="remember-machine" class="form-label">
|
||||
<InputCheckbox @bind-Value="Input.RememberMachine" />
|
||||
Remember this machine
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Don't have access to your authenticator device? You can
|
||||
<a href="Account/LoginWithRecoveryCode?ReturnUrl=@ReturnUrl">log in with a recovery code</a>.
|
||||
</p>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private bool RememberMe { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
// Ensure the user has gone through the username & password screen first
|
||||
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
|
||||
throw new InvalidOperationException("Unable to load two-factor authentication user.");
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty);
|
||||
var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine);
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
|
||||
message = "Error: Invalid authenticator code.";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Authenticator code")]
|
||||
public string? TwoFactorCode { get; set; }
|
||||
|
||||
[Display(Name = "Remember this machine")]
|
||||
public bool RememberMachine { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@page "/Account/LoginWithRecoveryCode"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<LoginWithRecoveryCode> Logger
|
||||
|
||||
<PageTitle>Recovery code verification</PageTitle>
|
||||
|
||||
<h1>Recovery code verification</h1>
|
||||
<hr />
|
||||
<StatusMessage Message="@message" />
|
||||
<p>
|
||||
You have requested to log in with a recovery code. This login will not be remembered until you provide
|
||||
an authenticator app code at log in or disable 2FA and log in again.
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.RecoveryCode" id="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode" />
|
||||
<label for="Input.RecoveryCode" class="form-label">Recovery Code</label>
|
||||
<ValidationMessage For="() => Input.RecoveryCode" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
// Ensure the user has gone through the username & password screen first
|
||||
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
|
||||
throw new InvalidOperationException("Unable to load two-factor authentication user.");
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
|
||||
|
||||
var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User account locked out.");
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId);
|
||||
message = "Error: Invalid recovery code entered.";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Recovery Code")]
|
||||
public string RecoveryCode { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
@page "/Account/Manage/ChangePassword"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<ChangePassword> Logger
|
||||
|
||||
<PageTitle>Change password</PageTitle>
|
||||
|
||||
<h3>Change password</h3>
|
||||
<StatusMessage Message="@message" />
|
||||
<div class="row">
|
||||
<div class="col-xl-6">
|
||||
<EditForm Model="Input" FormName="change-password" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.OldPassword" id="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Enter the old password" />
|
||||
<label for="Input.OldPassword" class="form-label">Old password</label>
|
||||
<ValidationMessage For="() => Input.OldPassword" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.NewPassword" id="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Enter the new password" />
|
||||
<label for="Input.NewPassword" class="form-label">New password</label>
|
||||
<ValidationMessage For="() => Input.NewPassword" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Enter the new password" />
|
||||
<label for="Input.ConfirmPassword" class="form-label">Confirm password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Update password</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser? user;
|
||||
private bool hasPassword;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
hasPassword = await UserManager.HasPasswordAsync(user);
|
||||
if (!hasPassword)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/Manage/SetPassword");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
|
||||
if (!changePasswordResult.Succeeded)
|
||||
{
|
||||
message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}";
|
||||
return;
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
Logger.LogInformation("User changed their password successfully.");
|
||||
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Your password has been changed", HttpContext);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Current password")]
|
||||
public string OldPassword { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "New password")]
|
||||
public string NewPassword { get; set; } = "";
|
||||
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Confirm new password")]
|
||||
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
|
||||
public string ConfirmPassword { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
@page "/Account/Manage/DeletePersonalData"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<DeletePersonalData> Logger
|
||||
|
||||
<PageTitle>Delete Personal Data</PageTitle>
|
||||
|
||||
<StatusMessage Message="@message" />
|
||||
|
||||
<h3>Delete Personal Data</h3>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EditForm Model="Input" FormName="delete-user" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
@if (requirePassword)
|
||||
{
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" id="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password." />
|
||||
<label for="Input.Password" class="form-label">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
}
|
||||
<button class="w-100 btn btn-lg btn-danger" type="submit">Delete data and close my account</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser? user;
|
||||
private bool requirePassword;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
requirePassword = await UserManager.HasPasswordAsync(user);
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requirePassword && !await UserManager.CheckPasswordAsync(user, Input.Password))
|
||||
{
|
||||
message = "Error: Incorrect password.";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await UserManager.DeleteAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected error occurred deleting user.");
|
||||
}
|
||||
|
||||
await SignInManager.SignOutAsync();
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId);
|
||||
|
||||
RedirectManager.RedirectToCurrentPage();
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@page "/Account/Manage/Disable2fa"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<Disable2fa> Logger
|
||||
|
||||
<PageTitle>Disable two-factor authentication (2FA)</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
<h3>Disable two-factor authentication (2FA)</h3>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<strong>This action only disables 2FA.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
|
||||
used in an authenticator app you should <a href="Account/Manage/ResetAuthenticator">reset your authenticator keys.</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form @formname="disable-2fa" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-danger" type="submit">Disable 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ApplicationUser? user;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false);
|
||||
if (!disable2faResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected error occurred disabling 2FA.");
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId);
|
||||
RedirectManager.RedirectToWithStatus(
|
||||
"Account/Manage/TwoFactorAuthentication",
|
||||
"2fa has been disabled. You can reenable 2fa when you setup an authenticator app",
|
||||
HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
@page "/Account/Manage/Email"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Manage email</PageTitle>
|
||||
|
||||
<h3>Manage email</h3>
|
||||
|
||||
<StatusMessage Message="@message"/>
|
||||
<div class="row">
|
||||
<div class="col-xl-6">
|
||||
<form @onsubmit="OnSendEmailVerificationAsync" @formname="send-verification" id="send-verification-form" method="post">
|
||||
<AntiforgeryToken />
|
||||
</form>
|
||||
<EditForm Model="Input" FormName="change-email" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
@if (isEmailConfirmed)
|
||||
{
|
||||
<div class="form-floating mb-3 input-group">
|
||||
<input type="text" value="@email" id="email" class="form-control" placeholder="Enter your email" disabled />
|
||||
<div class="input-group-append">
|
||||
<span class="h-100 input-group-text text-success font-weight-bold">✓</span>
|
||||
</div>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="form-floating mb-3">
|
||||
<input type="text" value="@email" id="email" class="form-control" placeholder="Enter your email" disabled />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<button type="submit" class="btn btn-link" form="send-verification-form">Send verification email</button>
|
||||
</div>
|
||||
}
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.NewEmail" id="Input.NewEmail" class="form-control" autocomplete="email" aria-required="true" placeholder="Enter a new email" />
|
||||
<label for="Input.NewEmail" class="form-label">New email</label>
|
||||
<ValidationMessage For="() => Input.NewEmail" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Change email</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser? user;
|
||||
private string? email;
|
||||
private bool isEmailConfirmed;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm(FormName = "change-email")]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
email = await UserManager.GetEmailAsync(user);
|
||||
isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user);
|
||||
|
||||
Input.NewEmail ??= email;
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (Input.NewEmail is null || Input.NewEmail == email)
|
||||
{
|
||||
message = "Your email is unchanged.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmailChange").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code });
|
||||
|
||||
await EmailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
message = "Confirmation link to change email sent. Please check your email.";
|
||||
}
|
||||
|
||||
private async Task OnSendEmailVerificationAsync()
|
||||
{
|
||||
if (email is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
|
||||
|
||||
await EmailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
message = "Verification email sent. Please check your email.";
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[Display(Name = "New email")]
|
||||
public string? NewEmail { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
@page "/Account/Manage/EnableAuthenticator"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject UrlEncoder UrlEncoder
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<EnableAuthenticator> Logger
|
||||
|
||||
<PageTitle>Configure authenticator app</PageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()" StatusMessage="@message" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusMessage Message="@message" />
|
||||
<h3>Configure authenticator app</h3>
|
||||
<div>
|
||||
<p>To use an authenticator app go through the following steps:</p>
|
||||
<ol class="list">
|
||||
<li>
|
||||
<p>
|
||||
Download a two-factor authenticator app like Microsoft Authenticator for
|
||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825072">Android</a> and
|
||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825073">iOS</a> or
|
||||
Google Authenticator for
|
||||
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en">Android</a> and
|
||||
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8">iOS</a>.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Scan the QR Code or enter this key <kbd>@sharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
|
||||
<div class="alert alert-info">Learn how to <a href="https://go.microsoft.com/fwlink/?Linkid=852423">enable QR code generation</a>.</div>
|
||||
<div></div>
|
||||
<div data-url="@authenticatorUri"></div>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
|
||||
with a unique code. Enter the code in the confirmation box below.
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-xl-6">
|
||||
<EditForm Model="Input" FormName="send-code" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Code" id="Input.Code" class="form-control" autocomplete="off" placeholder="Enter the code" />
|
||||
<label for="Input.Code" class="control-label form-label">Verification Code</label>
|
||||
<ValidationMessage For="() => Input.Code" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Verify</button>
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
|
||||
|
||||
private string? message;
|
||||
private ApplicationUser? user;
|
||||
private string? sharedKey;
|
||||
private string? authenticatorUri;
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadSharedKeyAndQrCodeUriAsync(user);
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip spaces and hyphens
|
||||
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
|
||||
|
||||
var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
|
||||
user, UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
|
||||
|
||||
if (!is2faTokenValid)
|
||||
{
|
||||
message = "Error: Verification code is invalid.";
|
||||
return;
|
||||
}
|
||||
|
||||
await UserManager.SetTwoFactorEnabledAsync(user, true);
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
|
||||
|
||||
message = "Your authenticator app has been verified.";
|
||||
|
||||
if (await UserManager.CountRecoveryCodesAsync(user) == 0)
|
||||
{
|
||||
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
||||
}
|
||||
else
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Manage/TwoFactorAuthentication", message, HttpContext);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user)
|
||||
{
|
||||
// Load the authenticator key & QR code URI to display on the form
|
||||
var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
|
||||
if (string.IsNullOrEmpty(unformattedKey))
|
||||
{
|
||||
await UserManager.ResetAuthenticatorKeyAsync(user);
|
||||
unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
|
||||
}
|
||||
|
||||
sharedKey = FormatKey(unformattedKey!);
|
||||
|
||||
var email = await UserManager.GetEmailAsync(user);
|
||||
authenticatorUri = GenerateQrCodeUri(email!, unformattedKey!);
|
||||
}
|
||||
|
||||
private string FormatKey(string unformattedKey)
|
||||
{
|
||||
var result = new StringBuilder();
|
||||
int currentPosition = 0;
|
||||
while (currentPosition + 4 < unformattedKey.Length)
|
||||
{
|
||||
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
|
||||
currentPosition += 4;
|
||||
}
|
||||
if (currentPosition < unformattedKey.Length)
|
||||
{
|
||||
result.Append(unformattedKey.AsSpan(currentPosition));
|
||||
}
|
||||
|
||||
return result.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string GenerateQrCodeUri(string email, string unformattedKey)
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
AuthenticatorUriFormat,
|
||||
UrlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"),
|
||||
UrlEncoder.Encode(email),
|
||||
unformattedKey);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Verification Code")]
|
||||
public string Code { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
@page "/Account/Manage/ExternalLogins"
|
||||
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IUserStore<ApplicationUser> UserStore
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Manage your external logins</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
@if (currentLogins?.Count > 0)
|
||||
{
|
||||
<h3>Registered Logins</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@foreach (var login in currentLogins)
|
||||
{
|
||||
<tr>
|
||||
<td>@login.ProviderDisplayName</td>
|
||||
<td>
|
||||
@if (showRemoveButton)
|
||||
{
|
||||
<form @formname="@($"remove-login-{login.LoginProvider}")" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<div>
|
||||
<input type="hidden" name="@nameof(LoginProvider)" value="@login.LoginProvider" />
|
||||
<input type="hidden" name="@nameof(ProviderKey)" value="@login.ProviderKey" />
|
||||
<button type="submit" class="btn btn-primary" title="Remove this @login.ProviderDisplayName login from your account">Remove</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
@:
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (otherLogins?.Count > 0)
|
||||
{
|
||||
<h4>Add another service to log in.</h4>
|
||||
<hr />
|
||||
<form class="form-horizontal" action="Account/Manage/LinkExternalLogin" method="post">
|
||||
<AntiforgeryToken />
|
||||
<div>
|
||||
<p>
|
||||
@foreach (var provider in otherLogins)
|
||||
{
|
||||
<button type="submit" class="btn btn-primary" name="Provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">
|
||||
@provider.DisplayName
|
||||
</button>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
public const string LinkLoginCallbackAction = "LinkLoginCallback";
|
||||
|
||||
private ApplicationUser? user;
|
||||
private IList<UserLoginInfo>? currentLogins;
|
||||
private IList<AuthenticationScheme>? otherLogins;
|
||||
private bool showRemoveButton;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private string? LoginProvider { get; set; }
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private string? ProviderKey { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Action { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
currentLogins = await UserManager.GetLoginsAsync(user);
|
||||
otherLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync())
|
||||
.Where(auth => currentLogins.All(ul => auth.Name != ul.LoginProvider))
|
||||
.ToList();
|
||||
|
||||
string? passwordHash = null;
|
||||
if (UserStore is IUserPasswordStore<ApplicationUser> userPasswordStore)
|
||||
{
|
||||
passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
showRemoveButton = passwordHash is not null || currentLogins.Count > 1;
|
||||
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method) && Action == LinkLoginCallbackAction)
|
||||
{
|
||||
await OnGetLinkLoginCallbackAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await UserManager.RemoveLoginAsync(user, LoginProvider!, ProviderKey!);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not removed.", HttpContext);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("The external login was removed.", HttpContext);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnGetLinkLoginCallbackAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var info = await SignInManager.GetExternalLoginInfoAsync(userId);
|
||||
if (info is null)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: Could not load external login info.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await UserManager.AddLoginAsync(user, info);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("The external login was added.", HttpContext);
|
||||
}
|
||||
else
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not added. External logins can only be associated with one account.", HttpContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@page "/Account/Manage/GenerateRecoveryCodes"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<GenerateRecoveryCodes> Logger
|
||||
|
||||
<PageTitle>Generate two-factor authentication (2FA) recovery codes</PageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()" StatusMessage="@message" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<h3>Generate two-factor authentication (2FA) recovery codes</h3>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-warning-sign"></span>
|
||||
<strong>Put these codes in a safe place.</strong>
|
||||
</p>
|
||||
<p>
|
||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
||||
</p>
|
||||
<p>
|
||||
Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
|
||||
used in an authenticator app you should <a href="Account/Manage/ResetAuthenticator">reset your authenticator keys.</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<form @formname="generate-recovery-codes" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-danger" type="submit">Generate Recovery Codes</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser? user;
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
|
||||
if (!isTwoFactorEnabled)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
||||
message = "You have generated new recovery codes.";
|
||||
|
||||
Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@page "/Account/Manage"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Profile</PageTitle>
|
||||
|
||||
<h3>Profile</h3>
|
||||
<StatusMessage />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl-6">
|
||||
<EditForm Model="Input" FormName="profile" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<input type="text" value="@username" id="username" class="form-control" placeholder="Choose your username." disabled />
|
||||
<label for="username" class="form-label">Username</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.PhoneNumber" id="Input.PhoneNumber" class="form-control" placeholder="Enter your phone number" />
|
||||
<label for="Input.PhoneNumber" class="form-label">Phone number</label>
|
||||
<ValidationMessage For="() => Input.PhoneNumber" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Save</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ApplicationUser? user;
|
||||
private string? username;
|
||||
private string? phoneNumber;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
username = await UserManager.GetUserNameAsync(user);
|
||||
phoneNumber = await UserManager.GetPhoneNumberAsync(user);
|
||||
|
||||
Input.PhoneNumber ??= phoneNumber;
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Input.PhoneNumber != phoneNumber)
|
||||
{
|
||||
var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
|
||||
if (!setPhoneResult.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Your profile has been updated", HttpContext);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Phone]
|
||||
[Display(Name = "Phone number")]
|
||||
public string? PhoneNumber { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
@page "/Account/Manage/Passkeys"
|
||||
|
||||
@using WishNinja.Data
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Buffers.Text
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Manage your passkeys</PageTitle>
|
||||
|
||||
<h3>Manage your passkeys</h3>
|
||||
|
||||
<StatusMessage />
|
||||
|
||||
@if (currentPasskeys is { Count: > 0 })
|
||||
{
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@foreach (var passkey in currentPasskeys)
|
||||
{
|
||||
<tr>
|
||||
<td>@(passkey.Name ?? "Unnamed passkey")</td>
|
||||
<td>
|
||||
@{
|
||||
var credentialId = Base64Url.EncodeToString(passkey.CredentialId);
|
||||
}
|
||||
<form @formname="@($"update-passkey-{credentialId}")" @onsubmit="UpdatePasskey" method="post">
|
||||
<AntiforgeryToken />
|
||||
<div>
|
||||
<input type="hidden" name="CredentialId" value="@credentialId" />
|
||||
<button type="submit" name="Action" value="rename" class="btn btn-primary" title="Rename this passkey">Rename</button>
|
||||
<button type="submit" name="Action" value="delete" class="btn btn-danger" title="Remove this passkey from your account">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>No passkeys are registered.</p>
|
||||
}
|
||||
|
||||
<form @formname="add-passkey" @onsubmit="AddPasskey" method="post">
|
||||
<AntiforgeryToken />
|
||||
@if (currentPasskeys is { Count: >= MaxPasskeyCount })
|
||||
{
|
||||
<p class="text-danger">You have reached the maximum number of allowed passkeys. Please delete one before adding a new one.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<PasskeySubmit Operation="PasskeyOperation.Create" Name="Input" class="btn btn-primary">Add a new passkey</PasskeySubmit>
|
||||
}
|
||||
|
||||
</form>
|
||||
|
||||
@code {
|
||||
private const int MaxPasskeyCount = 100;
|
||||
|
||||
private ApplicationUser? user;
|
||||
private IList<UserPasskeyInfo>? currentPasskeys;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private string? Action { get; set; }
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private string? CredentialId { get; set; }
|
||||
|
||||
[SupplyParameterFromForm(FormName = "add-passkey")]
|
||||
private PasskeyInputModel Input { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
currentPasskeys = await UserManager.GetPasskeysAsync(user);
|
||||
}
|
||||
|
||||
private async Task AddPasskey()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Input.Error))
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus($"Error: {Input.Error}", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Input.CredentialJson))
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: The browser did not provide a passkey.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPasskeys!.Count >= MaxPasskeyCount)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus($"Error: You have reached the maximum number of allowed passkeys.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var attestationResult = await SignInManager.PerformPasskeyAttestationAsync(Input.CredentialJson);
|
||||
if (!attestationResult.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus($"Error: Could not add the passkey: {attestationResult.Failure.Message}", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var addPasskeyResult = await UserManager.AddOrUpdatePasskeyAsync(user, attestationResult.Passkey);
|
||||
if (!addPasskeyResult.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be added to your account.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
// Immediately prompt the user to enter a name for the credential
|
||||
var credentialIdBase64Url = Base64Url.EncodeToString(attestationResult.Passkey.CredentialId);
|
||||
RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{credentialIdBase64Url}");
|
||||
}
|
||||
|
||||
private async Task UpdatePasskey()
|
||||
{
|
||||
switch (Action)
|
||||
{
|
||||
case "rename":
|
||||
RedirectManager.RedirectTo($"Account/Manage/RenamePasskey/{CredentialId}");
|
||||
break;
|
||||
case "delete":
|
||||
await DeletePasskey();
|
||||
break;
|
||||
default:
|
||||
RedirectManager.RedirectToCurrentPageWithStatus($"Error: Unknown action '{Action}'.", HttpContext);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeletePasskey()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] credentialId;
|
||||
try
|
||||
{
|
||||
credentialId = Base64Url.DecodeFromChars(CredentialId);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: The specified passkey ID had an invalid format.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await UserManager.RemovePasskeyAsync(user, credentialId);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: The passkey could not be deleted.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Passkey deleted successfully.", HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@page "/Account/Manage/PersonalData"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Personal Data</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
<h3>Personal Data</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p>Your account contains personal data that you have given us. This page allows you to download or delete that data.</p>
|
||||
<p>
|
||||
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
|
||||
</p>
|
||||
<form action="Account/Manage/DownloadPersonalData" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-primary" type="submit">Download</button>
|
||||
</form>
|
||||
<p>
|
||||
<a href="Account/Manage/DeletePersonalData" class="btn btn-danger">Delete</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
@page "/Account/Manage/RenamePasskey/{Id}"
|
||||
|
||||
@using WishNinja.Data
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using System.Buffers.Text
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<EditForm Model="Input" OnValidSubmit="Rename" FormName="rename-passkey" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
@if (passkey?.Name is { } name)
|
||||
{
|
||||
<h4>Enter a new name for your "@name" passkey</h4>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h4>Enter a name for your passkey</h4>
|
||||
}
|
||||
<hr />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Name" id="Input.Name" class="form-control" aria-required="true" placeholder="My passkey" />
|
||||
<label for="Input.Name" class="form-label">Passkey name</label>
|
||||
<ValidationMessage For="() => Input.Name" class="text-danger" />
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Continue</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private ApplicationUser? user;
|
||||
private UserPasskeyInfo? passkey;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
user = (await UserManager.GetUserAsync(HttpContext.User))!;
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] credentialId;
|
||||
try
|
||||
{
|
||||
credentialId = Base64Url.DecodeFromChars(Id);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey ID had an invalid format.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
passkey = await UserManager.GetPasskeyAsync(user, credentialId);
|
||||
if (passkey is null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The specified passkey could not be found.", HttpContext);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Rename()
|
||||
{
|
||||
passkey!.Name = Input.Name;
|
||||
var result = await UserManager.AddOrUpdatePasskeyAsync(user!, passkey);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Error: The passkey could not be updated.", HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
RedirectManager.RedirectToWithStatus("Account/Manage/Passkeys", "Passkey updated successfully.", HttpContext);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(200, ErrorMessage = "Passkey names must be no longer than {1} characters.")]
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@page "/Account/Manage/ResetAuthenticator"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<ResetAuthenticator> Logger
|
||||
|
||||
<PageTitle>Reset authenticator key</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
<h3>Reset authenticator key</h3>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-warning-sign"></span>
|
||||
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
|
||||
</p>
|
||||
<p>
|
||||
This process disables 2FA until you verify your authenticator app.
|
||||
If you do not complete your authenticator app configuration you may lose access to your account.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<form @formname="reset-authenticator" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-danger" type="submit">Reset authenticator key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
var user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
await UserManager.SetTwoFactorEnabledAsync(user, false);
|
||||
await UserManager.ResetAuthenticatorKeyAsync(user);
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId);
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
|
||||
RedirectManager.RedirectToWithStatus(
|
||||
"Account/Manage/EnableAuthenticator",
|
||||
"Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.",
|
||||
HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
@page "/Account/Manage/SetPassword"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Set password</PageTitle>
|
||||
|
||||
<h3>Set your password</h3>
|
||||
<StatusMessage Message="@message" />
|
||||
<p class="text-info">
|
||||
You do not have a local username/password for this site. Add a local
|
||||
account so you can log in without an external login.
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-xl-6">
|
||||
<EditForm Model="Input" FormName="set-password" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.NewPassword" id="Input.NewPassword" class="form-control" autocomplete="new-password" placeholder="Enter the new password" />
|
||||
<label for="Input.NewPassword" class="form-label">New password</label>
|
||||
<ValidationMessage For="() => Input.NewPassword" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Enter the new password" />
|
||||
<label for="Input.ConfirmPassword" class="form-label">Confirm password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Set password</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser? user;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var hasPassword = await UserManager.HasPasswordAsync(user);
|
||||
if (hasPassword)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/Manage/ChangePassword");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var addPasswordResult = await UserManager.AddPasswordAsync(user, Input.NewPassword!);
|
||||
if (!addPasswordResult.Succeeded)
|
||||
{
|
||||
message = $"Error: {string.Join(",", addPasswordResult.Errors.Select(error => error.Description))}";
|
||||
return;
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Your password has been set.", HttpContext);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "New password")]
|
||||
public string? NewPassword { get; set; }
|
||||
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Confirm new password")]
|
||||
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
|
||||
public string? ConfirmPassword { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
@page "/Account/Manage/TwoFactorAuthentication"
|
||||
|
||||
@using Microsoft.AspNetCore.Http.Features
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Two-factor authentication (2FA)</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
<h3>Two-factor authentication (2FA)</h3>
|
||||
@if (canTrack)
|
||||
{
|
||||
if (is2faEnabled)
|
||||
{
|
||||
if (recoveryCodesLeft == 0)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>You have no recovery codes left.</strong>
|
||||
<p>You must <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCodesLeft == 1)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>You have 1 recovery code left.</strong>
|
||||
<p>You can <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCodesLeft <= 3)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<strong>You have @recoveryCodesLeft recovery codes left.</strong>
|
||||
<p>You should <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (isMachineRemembered)
|
||||
{
|
||||
<form style="display: inline-block" @formname="forget-browser" @onsubmit="OnSubmitForgetBrowserAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="btn btn-primary">Forget this browser</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<a href="Account/Manage/Disable2fa" class="btn btn-primary">Disable 2FA</a>
|
||||
<a href="Account/Manage/GenerateRecoveryCodes" class="btn btn-primary">Reset recovery codes</a>
|
||||
}
|
||||
|
||||
<h4>Authenticator app</h4>
|
||||
@if (!hasAuthenticator)
|
||||
{
|
||||
<a href="Account/Manage/EnableAuthenticator" class="btn btn-primary">Add authenticator app</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="Account/Manage/EnableAuthenticator" class="btn btn-primary">Set up authenticator app</a>
|
||||
<a href="Account/Manage/ResetAuthenticator" class="btn btn-primary">Reset authenticator app</a>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>Privacy and cookie policy have not been accepted.</strong>
|
||||
<p>You must accept the policy before you can enable two factor authentication.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool canTrack;
|
||||
private bool hasAuthenticator;
|
||||
private int recoveryCodesLeft;
|
||||
private bool is2faEnabled;
|
||||
private bool isMachineRemembered;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var user = await UserManager.GetUserAsync(HttpContext.User);
|
||||
if (user is null)
|
||||
{
|
||||
RedirectManager.RedirectToInvalidUser(UserManager, HttpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
canTrack = HttpContext.Features.Get<ITrackingConsentFeature>()?.CanTrack ?? true;
|
||||
hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null;
|
||||
is2faEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
|
||||
isMachineRemembered = await SignInManager.IsTwoFactorClientRememberedAsync(user);
|
||||
recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user);
|
||||
}
|
||||
|
||||
private async Task OnSubmitForgetBrowserAsync()
|
||||
{
|
||||
await SignInManager.ForgetTwoFactorClientAsync();
|
||||
|
||||
RedirectManager.RedirectToCurrentPageWithStatus(
|
||||
"The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.",
|
||||
HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@layout ManageLayout
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@@ -0,0 +1,14 @@
|
||||
@page "/Account/Register"
|
||||
|
||||
<PageTitle>Registration by invite only</PageTitle>
|
||||
|
||||
<h1>Invitation required</h1>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
WishNinja is invite-only. Ask an administrator to send you an invitation email, then follow
|
||||
the link in that email to set up your account.
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Already have an account? <a href="Account/Login">Sign in</a>.
|
||||
</p>
|
||||
@@ -0,0 +1,69 @@
|
||||
@page "/Account/RegisterConfirmation"
|
||||
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Register confirmation</PageTitle>
|
||||
|
||||
<h1>Register confirmation</h1>
|
||||
|
||||
<StatusMessage Message="@statusMessage" />
|
||||
|
||||
@if (emailConfirmationLink is not null)
|
||||
{
|
||||
<p>
|
||||
This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these docs</a> for how to configure a real email sender.
|
||||
Normally this would be emailed: <a href="@emailConfirmationLink">Click here to confirm your account</a>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p role="alert">Please check your email to confirm your account.</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? emailConfirmationLink;
|
||||
private string? statusMessage;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Email { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (Email is null)
|
||||
{
|
||||
RedirectManager.RedirectTo("");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await UserManager.FindByEmailAsync(Email);
|
||||
if (user is null)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
statusMessage = "Error finding user for unspecified email";
|
||||
}
|
||||
else if (EmailSender is IdentityNoOpEmailSender)
|
||||
{
|
||||
// Once you add a real email sender, you should remove this code that lets you confirm the account
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
emailConfirmationLink = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@page "/Account/ResendEmailConfirmation"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Resend email confirmation</PageTitle>
|
||||
|
||||
<h1>Resend email confirmation</h1>
|
||||
<h2>Enter your email.</h2>
|
||||
<hr />
|
||||
<StatusMessage Message="@message" />
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" FormName="resend-email-confirmation" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" aria-required="true" placeholder="[email protected]" />
|
||||
<label for="Input.Email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Resend</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Input ??= new();
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var user = await UserManager.FindByEmailAsync(Input.Email!);
|
||||
if (user is null)
|
||||
{
|
||||
message = "Verification email sent. Please check your email.";
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
|
||||
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
message = "Verification email sent. Please check your email.";
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@page "/Account/ResetPassword"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
|
||||
<PageTitle>Reset password</PageTitle>
|
||||
|
||||
<h1>Reset password</h1>
|
||||
<h2>Reset your password.</h2>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<StatusMessage Message="@Message" />
|
||||
<EditForm Model="Input" FormName="reset-password" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
|
||||
<input type="hidden" name="Input.Code" value="@Input.Code" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="[email protected]" />
|
||||
<label for="Input.Email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" id="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password." />
|
||||
<label for="Input.Password" class="form-label">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password." />
|
||||
<label for="Input.ConfirmPassword" class="form-label">Confirm password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private IEnumerable<IdentityError>? identityErrors;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Code { get; set; }
|
||||
|
||||
private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Input ??= new();
|
||||
|
||||
if (Code is null)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/InvalidPasswordReset");
|
||||
return;
|
||||
}
|
||||
|
||||
Input.Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var user = await UserManager.FindByEmailAsync(Input.Email);
|
||||
if (user is null)
|
||||
{
|
||||
// Don't reveal that the user does not exist
|
||||
RedirectManager.RedirectTo("Account/ResetPasswordConfirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await UserManager.ResetPasswordAsync(user, Input.Code, Input.Password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/ResetPasswordConfirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
identityErrors = result.Errors;
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Confirm password")]
|
||||
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
|
||||
public string ConfirmPassword { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
public string Code { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@page "/Account/ResetPasswordConfirmation"
|
||||
<PageTitle>Reset password confirmation</PageTitle>
|
||||
|
||||
<h1>Reset password confirmation</h1>
|
||||
<p role="alert">
|
||||
Your password has been reset. Please <a href="Account/Login">click here to log in</a>.
|
||||
</p>
|
||||
@@ -0,0 +1,2 @@
|
||||
@using WishNinja.Components.Account.Shared
|
||||
@attribute [ExcludeFromInteractiveRouting]
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace WishNinja.Components.Account;
|
||||
|
||||
public class PasskeyInputModel
|
||||
{
|
||||
public string? CredentialJson { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace WishNinja.Components.Account;
|
||||
|
||||
public enum PasskeyOperation
|
||||
{
|
||||
Create = 0,
|
||||
Request = 1,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
@if (externalLogins.Length == 0)
|
||||
{
|
||||
<div>
|
||||
<p>
|
||||
There are no external authentication services configured. See this <a href="https://go.microsoft.com/fwlink/?LinkID=532715">article
|
||||
about setting up this ASP.NET application to support logging in via external services</a>.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form class="form-horizontal" action="Account/PerformExternalLogin" method="post">
|
||||
<div>
|
||||
<AntiforgeryToken />
|
||||
<input type="hidden" name="ReturnUrl" value="@ReturnUrl" />
|
||||
<p>
|
||||
@foreach (var provider in externalLogins)
|
||||
{
|
||||
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
private AuthenticationScheme[] externalLogins = [];
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
externalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@inherits LayoutComponentBase
|
||||
@layout WishNinja.Components.Layout.MainLayout
|
||||
|
||||
<h1>Manage your account</h1>
|
||||
|
||||
<div>
|
||||
<h2>Change your account settings</h2>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<ManageNavMenu />
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using WishNinja.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
|
||||
<ul class="nav nav-pills flex-column">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage" Match="NavLinkMatch.All">Profile</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/Email">Email</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/ChangePassword">Password</NavLink>
|
||||
</li>
|
||||
@if (hasExternalLogins)
|
||||
{
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/ExternalLogins">External logins</NavLink>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/TwoFactorAuthentication">Two-factor authentication</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/Passkeys">Passkeys</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/PersonalData">Personal data</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@code {
|
||||
private bool hasExternalLogins;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@using Microsoft.AspNetCore.Antiforgery
|
||||
@inject IServiceProvider Services
|
||||
|
||||
<button type="submit" name="__passkeySubmit" @attributes="AdditionalAttributes">@ChildContent</button>
|
||||
<passkey-submit
|
||||
operation="@Operation"
|
||||
name="@Name"
|
||||
email-name="@EmailName"
|
||||
request-token-name="@tokens?.HeaderName"
|
||||
request-token-value="@tokens?.RequestToken">
|
||||
</passkey-submit>
|
||||
|
||||
@code {
|
||||
private AntiforgeryTokenSet? tokens;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public PasskeyOperation Operation { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public string Name { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public string? EmailName { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public IDictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
tokens = Services.GetService<IAntiforgery>()?.GetTokens(HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
const browserSupportsPasskeys =
|
||||
typeof navigator.credentials !== 'undefined' &&
|
||||
typeof window.PublicKeyCredential !== 'undefined' &&
|
||||
typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' &&
|
||||
typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === 'function';
|
||||
|
||||
async function fetchWithErrorHandling(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...options
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error(text);
|
||||
throw new Error(`The server responded with status ${response.status}.`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function createCredential(headers, signal) {
|
||||
const optionsResponse = await fetchWithErrorHandling('/Account/PasskeyCreationOptions', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
const optionsJson = await optionsResponse.json();
|
||||
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
|
||||
return await navigator.credentials.create({ publicKey: options, signal });
|
||||
}
|
||||
|
||||
async function requestCredential(email, mediation, headers, signal) {
|
||||
const optionsResponse = await fetchWithErrorHandling(`/Account/PasskeyRequestOptions?username=${email}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
const optionsJson = await optionsResponse.json();
|
||||
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
|
||||
return await navigator.credentials.get({ publicKey: options, mediation, signal });
|
||||
}
|
||||
|
||||
customElements.define('passkey-submit', class extends HTMLElement {
|
||||
static formAssociated = true;
|
||||
|
||||
connectedCallback() {
|
||||
this.internals = this.attachInternals();
|
||||
this.attrs = {
|
||||
operation: this.getAttribute('operation'),
|
||||
name: this.getAttribute('name'),
|
||||
emailName: this.getAttribute('email-name'),
|
||||
requestTokenName: this.getAttribute('request-token-name'),
|
||||
requestTokenValue: this.getAttribute('request-token-value'),
|
||||
};
|
||||
|
||||
this.internals.form.addEventListener('submit', (event) => {
|
||||
if (event.submitter?.name === '__passkeySubmit') {
|
||||
event.preventDefault();
|
||||
this.obtainAndSubmitCredential();
|
||||
}
|
||||
});
|
||||
|
||||
this.tryAutofillPasskey();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
async obtainCredential(useConditionalMediation, signal) {
|
||||
if (!browserSupportsPasskeys) {
|
||||
throw new Error('Some passkey features are missing. Please update your browser.');
|
||||
}
|
||||
|
||||
const headers = {
|
||||
[this.attrs.requestTokenName]: this.attrs.requestTokenValue,
|
||||
};
|
||||
|
||||
if (this.attrs.operation === 'Create') {
|
||||
return await createCredential(headers, signal);
|
||||
} else if (this.attrs.operation === 'Request') {
|
||||
const email = new FormData(this.internals.form).get(this.attrs.emailName);
|
||||
const mediation = useConditionalMediation ? 'conditional' : undefined;
|
||||
return await requestCredential(email, mediation, headers, signal);
|
||||
} else {
|
||||
throw new Error(`Unknown passkey operation '${this.attrs.operation}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
async obtainAndSubmitCredential(useConditionalMediation = false) {
|
||||
this.abortController?.abort();
|
||||
this.abortController = new AbortController();
|
||||
const signal = this.abortController.signal;
|
||||
const formData = new FormData();
|
||||
try {
|
||||
const credential = await this.obtainCredential(useConditionalMediation, signal);
|
||||
const credentialJson = JSON.stringify(credential);
|
||||
formData.append(`${this.attrs.name}.CredentialJson`, credentialJson);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// The user explicitly canceled the operation - return without error.
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
if (useConditionalMediation) {
|
||||
// An error occurred during conditional mediation, which is not user-initiated.
|
||||
// We log the error in the console but do not relay it to the user.
|
||||
return;
|
||||
}
|
||||
const errorMessage = error.name === 'NotAllowedError'
|
||||
? 'No passkey was provided by the authenticator.'
|
||||
: error.message;
|
||||
formData.append(`${this.attrs.name}.Error`, errorMessage);
|
||||
}
|
||||
this.internals.setFormValue(formData);
|
||||
this.internals.form.submit();
|
||||
}
|
||||
|
||||
async tryAutofillPasskey() {
|
||||
if (browserSupportsPasskeys && this.attrs.operation === 'Request' && await PublicKeyCredential.isConditionalMediationAvailable?.()) {
|
||||
await this.obtainAndSubmitCredential(/* useConditionalMediation */ true);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<StatusMessage Message="@StatusMessage" />
|
||||
<h3>Recovery codes</h3>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<strong>Put these codes in a safe place.</strong>
|
||||
</p>
|
||||
<p>
|
||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
@foreach (var recoveryCode in RecoveryCodes)
|
||||
{
|
||||
<div>
|
||||
<code class="recovery-code">@recoveryCode</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string[] RecoveryCodes { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string? StatusMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
@if (!string.IsNullOrEmpty(DisplayMessage))
|
||||
{
|
||||
var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success";
|
||||
<div class="alert alert-@statusMessageClass" role="alert">
|
||||
@DisplayMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? messageFromCookie;
|
||||
|
||||
[Parameter]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
private string? DisplayMessage => Message ?? messageFromCookie;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName];
|
||||
|
||||
if (messageFromCookie is not null)
|
||||
{
|
||||
HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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["WishNinja.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@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,98 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 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,84 @@
|
||||
@implements IDisposable
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">🥷 WishNinja</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="wishlists">
|
||||
<span class="bi bi-card-list-nav-menu" aria-hidden="true"></span> My Wishlists
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="shared">
|
||||
<span class="bi bi-people-fill-nav-menu" aria-hidden="true"></span> Shared With Me
|
||||
</NavLink>
|
||||
</div>
|
||||
<AuthorizeView Roles="Admin" Context="adminCtx">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="admin/users">
|
||||
<span class="bi bi-shield-lock-nav-menu" aria-hidden="true"></span> Admin
|
||||
</NavLink>
|
||||
</div>
|
||||
</AuthorizeView>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Account/Manage">
|
||||
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<form action="Account/Logout" method="post">
|
||||
<AntiforgeryToken />
|
||||
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
|
||||
<button type="submit" class="nav-link">
|
||||
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Account/Login">
|
||||
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
|
||||
</NavLink>
|
||||
</div>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? currentUrl;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-lock-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zM5 8h6a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-person-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person' viewBox='0 0 16 16'%3E%3Cpath d='M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-person-badge-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-badge' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'/%3E%3Cpath d='M4.5 0A2.5 2.5 0 0 0 2 2.5V14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2.5A2.5 2.5 0 0 0 11.5 0h-7zM3 2.5A1.5 1.5 0 0 1 4.5 1h7A1.5 1.5 0 0 1 13 2.5v10.795a4.2 4.2 0 0 0-.776-.492C11.392 12.387 10.063 12 8 12s-3.392.387-4.224.803a4.2 4.2 0 0 0-.776.492V2.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-person-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-fill' viewBox='0 0 16 16'%3E%3Cpath d='M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3Zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-arrow-bar-left-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-bar-left' viewBox='0 0 16 16'%3E%3Cpath d='M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5ZM10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -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,142 @@
|
||||
@page "/admin/invites"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
@rendermode InteractiveServer
|
||||
@inject InviteService InviteSvc
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
|
||||
<PageTitle>Admin · Invitations</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Invitations</h1>
|
||||
<a class="btn btn-outline-secondary" href="admin/users">Back to users</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Invite someone</h5>
|
||||
<EditForm Model="form" OnValidSubmit="CreateAsync">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Email</label>
|
||||
<InputText class="form-control" @bind-Value="form.Email" placeholder="[email protected]" />
|
||||
<ValidationMessage For="() => form.Email" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Role</label>
|
||||
<InputSelect class="form-select" @bind-Value="form.Role">
|
||||
<option value="User">User</option>
|
||||
<option value="Admin">Admin</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary w-100">Send invite</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
@if (message is not null)
|
||||
{
|
||||
<div class="alert @(isError ? "alert-danger" : "alert-success") mt-3 mb-0">
|
||||
@message
|
||||
@if (lastAcceptUrl is not null)
|
||||
{
|
||||
<div class="small mt-1">
|
||||
If email isn't configured, share this link directly:<br />
|
||||
<code>@lastAcceptUrl</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Pending invitations</h5>
|
||||
@if (pending is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (pending.Count == 0)
|
||||
{
|
||||
<p class="text-secondary">No pending invitations.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead><tr><th>Email</th><th>Role</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var i in pending)
|
||||
{
|
||||
<tr>
|
||||
<td>@i.Email</td>
|
||||
<td>@i.Role</td>
|
||||
<td class="small @(i.ExpiresAt < DateTimeOffset.UtcNow ? "text-danger" : "")">
|
||||
@i.ExpiresAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")
|
||||
@if (i.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
{
|
||||
<span>(expired)</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => RevokeAsync(i.Id)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private InviteForm form = new();
|
||||
private List<Invite>? pending;
|
||||
private string? message;
|
||||
private string? lastAcceptUrl;
|
||||
private bool isError;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
pending = await db.Invites
|
||||
.Where(i => i.AcceptedAt == null)
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
message = null;
|
||||
lastAcceptUrl = null;
|
||||
var adminId = await AuthState.GetUserIdAsync();
|
||||
var result = await InviteSvc.CreateInviteAsync(form.Email, form.Role, adminId!);
|
||||
isError = !result.Succeeded;
|
||||
if (result.Succeeded)
|
||||
{
|
||||
message = $"Invitation sent to {form.Email}.";
|
||||
lastAcceptUrl = result.AcceptUrl;
|
||||
form = new InviteForm();
|
||||
}
|
||||
else
|
||||
{
|
||||
message = result.Error;
|
||||
}
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task RevokeAsync(int id)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.Invites.Where(i => i.Id == id).ExecuteDeleteAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private sealed class InviteForm
|
||||
{
|
||||
[Required, EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
public string Role { get; set; } = "User";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
@page "/admin/users"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
@rendermode InteractiveServer
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
|
||||
<PageTitle>Admin · Users</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>Users</h1>
|
||||
<a class="btn btn-primary" href="admin/invites">Manage invitations</a>
|
||||
</div>
|
||||
|
||||
@if (users is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Email</th><th>Joined</th><th>Roles</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var u in users)
|
||||
{
|
||||
<tr>
|
||||
<td>@u.DisplayName</td>
|
||||
<td>@u.Email</td>
|
||||
<td class="small text-secondary">@u.CreatedAt.ToString("yyyy-MM-dd")</td>
|
||||
<td>
|
||||
@if (adminIds.Contains(u.Id))
|
||||
{
|
||||
<span class="badge bg-primary">Admin</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">User</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (u.IsDisabled)
|
||||
{
|
||||
<span class="badge bg-danger">Disabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (u.Id != currentUserId)
|
||||
{
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => ToggleAdmin(u)">
|
||||
@(adminIds.Contains(u.Id) ? "Revoke admin" : "Make admin")
|
||||
</button>
|
||||
<button class="btn btn-sm @(u.IsDisabled ? "btn-outline-success" : "btn-outline-danger")"
|
||||
@onclick="() => ToggleDisabled(u)">
|
||||
@(u.IsDisabled ? "Enable" : "Disable")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-secondary small">(you)</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ApplicationUser>? users;
|
||||
private HashSet<string> adminIds = new();
|
||||
private string? currentUserId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
currentUserId = await AuthState.GetUserIdAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
users = await db.Users.OrderBy(u => u.DisplayName).ToListAsync();
|
||||
var admins = await UserManager.GetUsersInRoleAsync("Admin");
|
||||
adminIds = admins.Select(a => a.Id).ToHashSet();
|
||||
}
|
||||
|
||||
private async Task ToggleAdmin(ApplicationUser u)
|
||||
{
|
||||
var user = await UserManager.FindByIdAsync(u.Id);
|
||||
if (user is null) return;
|
||||
if (adminIds.Contains(u.Id))
|
||||
await UserManager.RemoveFromRoleAsync(user, "Admin");
|
||||
else
|
||||
await UserManager.AddToRoleAsync(user, "Admin");
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task ToggleDisabled(ApplicationUser u)
|
||||
{
|
||||
var user = await UserManager.FindByIdAsync(u.Id);
|
||||
if (user is null) return;
|
||||
var disable = !user.IsDisabled;
|
||||
user.IsDisabled = disable;
|
||||
await UserManager.SetLockoutEnabledAsync(user, true);
|
||||
await UserManager.SetLockoutEndDateAsync(user, disable ? DateTimeOffset.MaxValue : null);
|
||||
await UserManager.UpdateAsync(user);
|
||||
// Invalidate any active session by rotating the security stamp.
|
||||
await UserManager.UpdateSecurityStampAsync(user);
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
@@ -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,45 @@
|
||||
@page "/"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inject WishlistService Wishlists
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
|
||||
<PageTitle>WishNinja</PageTitle>
|
||||
|
||||
<h1>🥷 WishNinja</h1>
|
||||
<p class="lead">Make wishes. Claim gifts. Keep the surprise.</p>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">My Wishlists</h5>
|
||||
<p class="card-text">You have @ownedCount wishlist(s).</p>
|
||||
<a class="btn btn-primary" href="wishlists">Manage my wishlists</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Shared With Me</h5>
|
||||
<p class="card-text">@sharedCount list(s) shared with you.</p>
|
||||
<a class="btn btn-outline-primary" href="shared">Browse shared wishlists</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private int ownedCount;
|
||||
private int sharedCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var userId = await AuthState.GetUserIdAsync();
|
||||
if (userId is null) return;
|
||||
ownedCount = (await Wishlists.GetOwnedAsync(userId)).Count;
|
||||
sharedCount = (await Wishlists.GetSharedWithAsync(userId)).Count;
|
||||
}
|
||||
}
|
||||
@@ -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,43 @@
|
||||
@page "/shared"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inject WishlistService Wishlists
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
|
||||
<PageTitle>Shared With Me</PageTitle>
|
||||
|
||||
<h1>Shared With Me</h1>
|
||||
|
||||
@if (lists is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (lists.Count == 0)
|
||||
{
|
||||
<p class="text-secondary">No wishlists have been shared with you yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (var w in lists)
|
||||
{
|
||||
<a href="@($"wishlist/{w.Id}")" class="list-group-item list-group-item-action">
|
||||
<strong>@w.Title</strong>
|
||||
<span class="text-secondary">— @(w.Owner?.DisplayName ?? "Unknown")</span>
|
||||
<div class="text-secondary small">@w.Description</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Wishlist>? lists;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var userId = await AuthState.GetUserIdAsync();
|
||||
if (userId is null) return;
|
||||
lists = await Wishlists.GetSharedWithAsync(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
@page "/wishlist/{Id:int}"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inject WishlistService Wishlists
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>@(view?.Wishlist.Title ?? "Wishlist")</PageTitle>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (view is null)
|
||||
{
|
||||
<div class="alert alert-warning">This wishlist doesn't exist or you don't have access to it.</div>
|
||||
<a href="wishlists">Back to my wishlists</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h1 class="mb-0">@view.Wishlist.Title</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(view.Wishlist.Description))
|
||||
{
|
||||
<p class="text-secondary">@view.Wishlist.Description</p>
|
||||
}
|
||||
@if (!view.IsViewerOwner)
|
||||
{
|
||||
<span class="badge bg-info text-dark">Viewing @ownerName's list</span>
|
||||
}
|
||||
</div>
|
||||
@if (view.IsViewerOwner)
|
||||
{
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => showSettings = !showSettings">Settings</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAddItem">+ Add item</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (view.IsViewerOwner)
|
||||
{
|
||||
<div class="alert alert-secondary py-2 small">
|
||||
🤫 To keep the surprise, claims on your own list are hidden from you.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showSettings && view.IsViewerOwner)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">List settings</h5>
|
||||
<EditForm Model="settings" OnValidSubmit="SaveSettings">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Title</label>
|
||||
<InputText class="form-control" @bind-Value="settings.Title" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Description</label>
|
||||
<InputTextArea class="form-control" @bind-Value="settings.Description" rows="2" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Who can see this list?</label>
|
||||
<InputSelect class="form-select" @bind-Value="settings.Visibility">
|
||||
<option value="@WishlistVisibility.AllMembers">All members</option>
|
||||
<option value="@WishlistVisibility.SpecificUsers">Only specific people</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (settings.Visibility == WishlistVisibility.SpecificUsers)
|
||||
{
|
||||
<div class="mb-2 border rounded p-2">
|
||||
<div class="small text-secondary mb-1">Share with:</div>
|
||||
@if (allUsers.Count == 0)
|
||||
{
|
||||
<div class="text-secondary small">No other users yet.</div>
|
||||
}
|
||||
@foreach (var u in allUsers)
|
||||
{
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@($"share-{u.Id}")"
|
||||
checked="@shareWith.Contains(u.Id)"
|
||||
@onchange="e => ToggleShare(u.Id, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@($"share-{u.Id}")">@u.DisplayName</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="form-check mb-2">
|
||||
<InputCheckbox class="form-check-input" id="archived" @bind-Value="settings.IsArchived" />
|
||||
<label class="form-check-label" for="archived">Archived (hidden from others)</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success btn-sm">Save settings</button>
|
||||
<button type="button" class="btn btn-link btn-sm" @onclick="() => showSettings = false">Cancel</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (addingItem && view.IsViewerOwner)
|
||||
{
|
||||
<ItemEditor WishlistId="view.Wishlist.Id" OnSaved="OnItemSaved" OnCancelled="() => addingItem = false" />
|
||||
}
|
||||
|
||||
@if (claimError is not null)
|
||||
{
|
||||
<div class="alert alert-warning py-2">@claimError</div>
|
||||
}
|
||||
|
||||
@if (view.Items.Count == 0)
|
||||
{
|
||||
<p class="text-secondary">No items yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
@foreach (var iv in view.Items)
|
||||
{
|
||||
<div class="col-md-6">
|
||||
@if (editingItemId == iv.Item.Id && view.IsViewerOwner)
|
||||
{
|
||||
<ItemEditor WishlistId="view.Wishlist.Id" Item="iv.Item"
|
||||
OnSaved="OnItemSaved" OnCancelled="() => editingItemId = null" />
|
||||
}
|
||||
else
|
||||
{
|
||||
var item = iv.Item;
|
||||
<div class="card h-100 @(iv.IsFullyClaimed ? "border-success" : "")">
|
||||
<div class="row g-0 h-100">
|
||||
@{
|
||||
string? img = item.ImageKind == ImageKind.Uploaded && item.ImagePath is not null
|
||||
? $"/uploads/{item.ImagePath}"
|
||||
: null;
|
||||
}
|
||||
@if (img is not null)
|
||||
{
|
||||
<div class="col-4">
|
||||
<img src="@img" alt="@item.Name" class="img-fluid rounded-start"
|
||||
style="object-fit:cover;height:100%;width:100%;" />
|
||||
</div>
|
||||
}
|
||||
<div class="@(img is not null ? "col-8" : "col-12")">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<h5 class="card-title mb-1">@item.Name</h5>
|
||||
<span class="badge bg-light text-dark" title="Priority">P@(item.Priority)</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
<p class="card-text small">@item.Description</p>
|
||||
}
|
||||
<div class="small text-secondary mb-2">
|
||||
@if (item.Price is not null)
|
||||
{
|
||||
<span class="me-2">@item.Price.Value.ToString("C")</span>
|
||||
}
|
||||
@if (item.Quantity > 1)
|
||||
{
|
||||
<span>Qty wanted: @item.Quantity</span>
|
||||
}
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(item.ProductUrl))
|
||||
{
|
||||
<a href="@item.ProductUrl" target="_blank" rel="noopener" class="btn btn-outline-secondary btn-sm mb-2">View product ↗</a>
|
||||
}
|
||||
|
||||
@if (view!.IsViewerOwner)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="() => editingItemId = item.Id">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => DeleteItem(item.Id)">Delete</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mt-2 border-top pt-2">
|
||||
@if (iv.ClaimedByViewer)
|
||||
{
|
||||
<div class="text-success small mb-1">✓ You've claimed this</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => UnclaimItem(iv)">Unclaim</button>
|
||||
}
|
||||
else if (iv.RemainingQuantity > 0)
|
||||
{
|
||||
@if (item.Quantity > 1)
|
||||
{
|
||||
<div class="input-group input-group-sm mb-1" style="max-width:160px;">
|
||||
<span class="input-group-text">Qty</span>
|
||||
<input type="number" class="form-control" min="1" max="@iv.RemainingQuantity"
|
||||
value="@GetClaimQty(iv)"
|
||||
@onchange="e => claimQty[item.Id] = int.TryParse(e.Value?.ToString(), out var v) ? v : 1" />
|
||||
</div>
|
||||
<div class="small text-secondary mb-1">@iv.RemainingQuantity of @item.Quantity left</div>
|
||||
}
|
||||
<button class="btn btn-success btn-sm" @onclick="() => ClaimItem(iv)">Claim</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-success small">Fully claimed</div>
|
||||
}
|
||||
|
||||
@if (iv.OtherClaims.Count > 0)
|
||||
{
|
||||
<div class="small text-secondary mt-2">
|
||||
Also claimed by:
|
||||
@string.Join(", ", iv.OtherClaims.Select(c => $"{c.ClaimedByDisplayName} (×{c.Quantity})"))
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private WishlistDetailView? view;
|
||||
private bool loading = true;
|
||||
private string? userId;
|
||||
private string ownerName = "";
|
||||
|
||||
private bool showSettings;
|
||||
private bool addingItem;
|
||||
private int? editingItemId;
|
||||
|
||||
// claim input state, keyed by item id
|
||||
private readonly Dictionary<int, int> claimQty = new();
|
||||
private string? claimError;
|
||||
|
||||
// settings state
|
||||
private SettingsModel settings = new();
|
||||
private List<ApplicationUser> allUsers = new();
|
||||
private HashSet<string> shareWith = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
userId = await AuthState.GetUserIdAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!loading && view?.Wishlist.Id != Id)
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
loading = true;
|
||||
if (userId is null) { loading = false; return; }
|
||||
view = await Wishlists.GetDetailAsync(Id, userId);
|
||||
if (view is not null)
|
||||
{
|
||||
ownerName = view.Wishlist.Owner?.DisplayName ?? "";
|
||||
if (view.IsViewerOwner)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ownerName)) ownerName = "you";
|
||||
settings = new SettingsModel
|
||||
{
|
||||
Title = view.Wishlist.Title,
|
||||
Description = view.Wishlist.Description,
|
||||
Visibility = view.Wishlist.Visibility,
|
||||
IsArchived = view.Wishlist.IsArchived,
|
||||
};
|
||||
shareWith = view.Wishlist.Shares.Select(s => s.UserId).ToHashSet();
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
allUsers = await db.Users.Where(u => u.Id != userId)
|
||||
.OrderBy(u => u.DisplayName).ToListAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// resolve owner name if not loaded
|
||||
if (string.IsNullOrEmpty(ownerName))
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var owner = await db.Users.FindAsync(view.Wishlist.OwnerId);
|
||||
ownerName = owner?.DisplayName ?? "someone";
|
||||
}
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private void StartAddItem()
|
||||
{
|
||||
editingItemId = null;
|
||||
addingItem = true;
|
||||
}
|
||||
|
||||
private async Task OnItemSaved()
|
||||
{
|
||||
addingItem = false;
|
||||
editingItemId = null;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteItem(int itemId)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.WishlistItems.Where(i => i.Id == itemId).ExecuteDeleteAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
// --- Claims (non-owner) --------------------------------------------------------------------
|
||||
|
||||
private int GetClaimQty(WishlistItemView iv)
|
||||
{
|
||||
if (!claimQty.TryGetValue(iv.Item.Id, out var q))
|
||||
q = Math.Min(1, iv.RemainingQuantity);
|
||||
return Math.Max(1, q);
|
||||
}
|
||||
|
||||
private async Task ClaimItem(WishlistItemView iv)
|
||||
{
|
||||
claimError = null;
|
||||
var result = await Wishlists.ClaimAsync(iv.Item.Id, userId!, GetClaimQty(iv), null);
|
||||
if (!result.Succeeded) claimError = result.Error;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task UnclaimItem(WishlistItemView iv)
|
||||
{
|
||||
claimError = null;
|
||||
var result = await Wishlists.UnclaimAsync(iv.Item.Id, userId!);
|
||||
if (!result.Succeeded) claimError = result.Error;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
// --- Settings (owner) ----------------------------------------------------------------------
|
||||
|
||||
private async Task SaveSettings()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var w = await db.Wishlists.Include(x => x.Shares).FirstAsync(x => x.Id == Id);
|
||||
w.Title = settings.Title.Trim();
|
||||
w.Description = settings.Description;
|
||||
w.Visibility = settings.Visibility;
|
||||
w.IsArchived = settings.IsArchived;
|
||||
|
||||
w.Shares.Clear();
|
||||
if (settings.Visibility == WishlistVisibility.SpecificUsers)
|
||||
{
|
||||
foreach (var uid in shareWith)
|
||||
w.Shares.Add(new WishlistShare { WishlistId = w.Id, UserId = uid });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
showSettings = false;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private void ToggleShare(string uid, bool on)
|
||||
{
|
||||
if (on) shareWith.Add(uid); else shareWith.Remove(uid);
|
||||
}
|
||||
|
||||
private sealed class SettingsModel
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public WishlistVisibility Visibility { get; set; }
|
||||
public bool IsArchived { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
@page "/wishlists"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>My Wishlists</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1>My Wishlists</h1>
|
||||
<button class="btn btn-primary" @onclick="StartCreate">+ New wishlist</button>
|
||||
</div>
|
||||
|
||||
@if (creating)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<EditForm Model="newList" OnValidSubmit="CreateAsync">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Title</label>
|
||||
<InputText class="form-control" @bind-Value="newList.Title" />
|
||||
<ValidationMessage For="() => newList.Title" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Description (optional)</label>
|
||||
<InputTextArea class="form-control" @bind-Value="newList.Description" rows="2" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success btn-sm">Create</button>
|
||||
<button type="button" class="btn btn-link btn-sm" @onclick="() => creating = false">Cancel</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (owned is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (owned.Count == 0)
|
||||
{
|
||||
<p class="text-secondary">You haven't created any wishlists yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (var w in owned)
|
||||
{
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href="@($"wishlist/{w.Id}")" class="text-decoration-none flex-grow-1">
|
||||
<strong>@w.Title</strong>
|
||||
@if (w.IsArchived)
|
||||
{
|
||||
<span class="badge bg-secondary ms-2">Archived</span>
|
||||
}
|
||||
<span class="badge bg-light text-dark ms-2">@w.Items.Count items</span>
|
||||
<div class="text-secondary small">@w.Description</div>
|
||||
</a>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => DeleteAsync(w)">Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Wishlist>? owned;
|
||||
private bool creating;
|
||||
private NewListModel newList = new();
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
var userId = await AuthState.GetUserIdAsync();
|
||||
if (userId is null) return;
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
owned = await db.Wishlists
|
||||
.Include(w => w.Items)
|
||||
.Where(w => w.OwnerId == userId)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private void StartCreate()
|
||||
{
|
||||
newList = new NewListModel();
|
||||
creating = true;
|
||||
}
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
var userId = await AuthState.GetUserIdAsync();
|
||||
if (userId is null) return;
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var w = new Wishlist { OwnerId = userId, Title = newList.Title.Trim(), Description = newList.Description };
|
||||
db.Wishlists.Add(w);
|
||||
await db.SaveChangesAsync();
|
||||
creating = false;
|
||||
Nav.NavigateTo($"wishlist/{w.Id}");
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Wishlist w)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.Wishlists.Where(x => x.Id == w.Id).ExecuteDeleteAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private sealed class NewListModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required, System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Title { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@using WishNinja.Components.Account.Shared
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,287 @@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.JSInterop
|
||||
@implements IAsyncDisposable
|
||||
@inject ImageService Images
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="card mb-3" id="@editorId">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">@(IsNew ? "Add item" : "Edit item")</h5>
|
||||
<EditForm Model="model" OnValidSubmit="SaveAsync">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" />
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Name</label>
|
||||
<InputText class="form-control" @bind-Value="model.Name" />
|
||||
<ValidationMessage For="() => model.Name" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Description</label>
|
||||
<InputTextArea class="form-control" @bind-Value="model.Description" rows="2" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-2">
|
||||
<label class="form-label">Product link</label>
|
||||
<InputText class="form-control" @bind-Value="model.ProductUrl" placeholder="https://…" />
|
||||
</div>
|
||||
<div class="col-md-2 mb-2">
|
||||
<label class="form-label">Price</label>
|
||||
<InputNumber class="form-control" @bind-Value="model.Price" />
|
||||
</div>
|
||||
<div class="col-md-2 mb-2">
|
||||
<label class="form-label">Priority</label>
|
||||
<InputSelect class="form-select" @bind-Value="model.Priority">
|
||||
<option value="1">1 – Must have</option>
|
||||
<option value="2">2 – High</option>
|
||||
<option value="3">3 – Normal</option>
|
||||
<option value="4">4 – Low</option>
|
||||
<option value="5">5 – Someday</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-2 mb-2">
|
||||
<label class="form-label">Quantity</label>
|
||||
<InputNumber class="form-control" @bind-Value="model.Quantity" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 border rounded p-2 bg-light">
|
||||
<label class="form-label fw-bold">Image</label>
|
||||
<div class="d-flex gap-3 align-items-start flex-wrap">
|
||||
<div>
|
||||
@if (PreviewUrl is not null)
|
||||
{
|
||||
<img src="@PreviewUrl" alt="preview" style="max-width:140px;max-height:140px;" class="border rounded" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-secondary small border rounded d-flex align-items-center justify-content-center"
|
||||
style="width:140px;height:140px;">No image</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-0">Upload a file</label>
|
||||
<InputFile class="form-control form-control-sm" OnChange="OnFileSelected" accept="image/*" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-0">…or fetch an image from a URL <span class="text-secondary">(downloaded & stored locally)</span></label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" @bind="externalUrlInput" placeholder="https://…/image.jpg" disabled="@downloadingUrl" />
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="ApplyImageUrl" disabled="@downloadingUrl">
|
||||
@(downloadingUrl ? "Fetching…" : "Fetch")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-secondary">
|
||||
…or just press <kbd>Ctrl</kbd>+<kbd>V</kbd> to paste an image from your clipboard.
|
||||
</div>
|
||||
@if (imageError is not null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@imageError</div>
|
||||
}
|
||||
@if (model.ImageKind != ImageKind.None)
|
||||
{
|
||||
<button type="button" class="btn btn-link btn-sm text-danger px-0" @onclick="RemoveImage">Remove image</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-link btn-sm" @onclick="CancelAsync">Cancel</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int WishlistId { get; set; }
|
||||
[Parameter] public WishlistItem? Item { get; set; }
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
[Parameter] public EventCallback OnCancelled { get; set; }
|
||||
|
||||
private readonly string editorId = $"item-editor-{Guid.NewGuid():N}";
|
||||
private ItemModel model = new();
|
||||
private string? externalUrlInput;
|
||||
private string? imageError;
|
||||
private string? originalUploadedFile; // existing uploaded file when editor opened
|
||||
private string? stagedUploadedFile; // newly uploaded file not yet committed
|
||||
private DotNetObjectReference<ItemEditor>? selfRef;
|
||||
private IJSObjectReference? jsModule;
|
||||
private IJSObjectReference? pasteSub;
|
||||
|
||||
private bool IsNew => Item is null;
|
||||
|
||||
private string? PreviewUrl =>
|
||||
model.ImageKind == ImageKind.Uploaded && model.ImagePath is not null
|
||||
? $"/uploads/{model.ImagePath}"
|
||||
: null;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Item is not null)
|
||||
{
|
||||
model = new ItemModel
|
||||
{
|
||||
Name = Item.Name,
|
||||
Description = Item.Description,
|
||||
ProductUrl = Item.ProductUrl,
|
||||
Price = Item.Price,
|
||||
Priority = Item.Priority,
|
||||
Quantity = Item.Quantity,
|
||||
ImageKind = Item.ImageKind,
|
||||
ImagePath = Item.ImagePath,
|
||||
};
|
||||
originalUploadedFile = Item.ImageKind == ImageKind.Uploaded ? Item.ImagePath : null;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/itemEditor.js");
|
||||
selfRef = DotNetObjectReference.Create(this);
|
||||
pasteSub = await jsModule.InvokeAsync<IJSObjectReference>("registerPaste", editorId, selfRef);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
imageError = null;
|
||||
var file = e.File;
|
||||
if (!Images.IsAllowedContentType(file.ContentType))
|
||||
{
|
||||
imageError = "Unsupported image type. Use PNG, JPEG, WebP or GIF.";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream(Images.MaxBytes);
|
||||
var result = await Images.SaveAsync(stream, file.ContentType);
|
||||
ApplySaveResult(result);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
imageError = $"Upload failed — the file may exceed the {Images.MaxBytes / (1024 * 1024)} MB limit.";
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnImagePasted(string base64, string contentType)
|
||||
{
|
||||
imageError = null;
|
||||
if (!Images.IsAllowedContentType(contentType))
|
||||
{
|
||||
imageError = "Unsupported image type pasted.";
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
using var stream = new MemoryStream(bytes);
|
||||
var result = await Images.SaveAsync(stream, contentType);
|
||||
ApplySaveResult(result);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void ApplySaveResult(ImageSaveResult result)
|
||||
{
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
imageError = result.Error;
|
||||
return;
|
||||
}
|
||||
// Discard a previously staged (uncommitted) file before replacing it.
|
||||
if (stagedUploadedFile is not null) Images.Delete(stagedUploadedFile);
|
||||
stagedUploadedFile = result.FileName;
|
||||
model.ImageKind = ImageKind.Uploaded;
|
||||
model.ImagePath = result.FileName;
|
||||
externalUrlInput = null;
|
||||
}
|
||||
|
||||
private bool downloadingUrl;
|
||||
|
||||
private async Task ApplyImageUrl()
|
||||
{
|
||||
imageError = null;
|
||||
if (string.IsNullOrWhiteSpace(externalUrlInput)) return;
|
||||
downloadingUrl = true;
|
||||
var result = await Images.SaveFromUrlAsync(externalUrlInput);
|
||||
ApplySaveResult(result); // stores locally as an Uploaded image
|
||||
downloadingUrl = false;
|
||||
}
|
||||
|
||||
private void RemoveImage()
|
||||
{
|
||||
if (stagedUploadedFile is not null) { Images.Delete(stagedUploadedFile); stagedUploadedFile = null; }
|
||||
model.ImageKind = ImageKind.None;
|
||||
model.ImagePath = null;
|
||||
externalUrlInput = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
WishlistItem entity;
|
||||
if (IsNew)
|
||||
{
|
||||
entity = new WishlistItem { WishlistId = WishlistId };
|
||||
db.WishlistItems.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entity = await db.WishlistItems.FirstAsync(i => i.Id == Item!.Id);
|
||||
}
|
||||
|
||||
entity.Name = model.Name.Trim();
|
||||
entity.Description = model.Description;
|
||||
entity.ProductUrl = model.ProductUrl;
|
||||
entity.Price = model.Price;
|
||||
entity.Priority = model.Priority;
|
||||
entity.Quantity = model.Quantity < 1 ? 1 : model.Quantity;
|
||||
entity.ImageKind = model.ImageKind;
|
||||
entity.ImagePath = model.ImagePath;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// The staged file is now committed; delete the previous uploaded file if it was replaced.
|
||||
if (originalUploadedFile is not null && originalUploadedFile != model.ImagePath)
|
||||
Images.Delete(originalUploadedFile);
|
||||
stagedUploadedFile = null;
|
||||
|
||||
await OnSaved.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task CancelAsync()
|
||||
{
|
||||
// Drop any uploaded-but-uncommitted file.
|
||||
if (stagedUploadedFile is not null && stagedUploadedFile != originalUploadedFile)
|
||||
Images.Delete(stagedUploadedFile);
|
||||
await OnCancelled.InvokeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (pasteSub is not null) { await pasteSub.InvokeVoidAsync("dispose"); await pasteSub.DisposeAsync(); }
|
||||
if (jsModule is not null) await jsModule.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* circuit gone */ }
|
||||
selfRef?.Dispose();
|
||||
}
|
||||
|
||||
private sealed class ItemModel
|
||||
{
|
||||
[Required, MaxLength(300)]
|
||||
public string Name { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
public string? ProductUrl { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
public int Priority { get; set; } = 3;
|
||||
public int Quantity { get; set; } = 1;
|
||||
public ImageKind ImageKind { get; set; } = ImageKind.None;
|
||||
public string? ImagePath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@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 Microsoft.EntityFrameworkCore
|
||||
@using WishNinja
|
||||
@using WishNinja.Components
|
||||
@using WishNinja.Components.Layout
|
||||
@using WishNinja.Components.Wishlists
|
||||
@using WishNinja.Data
|
||||
@using WishNinja.Data.Entities
|
||||
@using WishNinja.Services
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace WishNinja.Configuration;
|
||||
|
||||
/// <summary>Strongly-typed app configuration bound from the "WishNinja" section + env vars.</summary>
|
||||
public class WishNinjaOptions
|
||||
{
|
||||
public const string SectionName = "WishNinja";
|
||||
|
||||
/// <summary>Public base URL used to build absolute links in emails (no trailing slash).</summary>
|
||||
public string BaseUrl { get; set; } = "https://localhost:5001";
|
||||
|
||||
/// <summary>Filesystem directory for the SQLite db, uploads and data-protection keys.</summary>
|
||||
public string DataPath { get; set; } = "Data";
|
||||
|
||||
public SmtpOptions Smtp { get; set; } = new();
|
||||
public UploadOptions Uploads { get; set; } = new();
|
||||
public InviteOptions Invites { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SmtpOptions
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
public int Port { get; set; } = 587;
|
||||
public bool UseStartTls { get; set; } = true;
|
||||
public string? User { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string FromAddress { get; set; } = "wishninja@localhost";
|
||||
public string FromName { get; set; } = "WishNinja";
|
||||
|
||||
/// <summary>True when a host is configured; otherwise emails are logged instead of sent.</summary>
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(Host);
|
||||
}
|
||||
|
||||
public class UploadOptions
|
||||
{
|
||||
/// <summary>Max accepted upload size in bytes (default 5 MB).</summary>
|
||||
public long MaxBytes { get; set; } = 5 * 1024 * 1024;
|
||||
}
|
||||
|
||||
public class InviteOptions
|
||||
{
|
||||
/// <summary>How long an invite link remains valid.</summary>
|
||||
public int ExpiryHours { get; set; } = 168; // 7 days
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using WishNinja.Components;
|
||||
using WishNinja.Components.Account;
|
||||
using WishNinja.Configuration;
|
||||
using WishNinja.Data;
|
||||
using WishNinja.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --- Options ---------------------------------------------------------------------------------
|
||||
builder.Services.Configure<WishNinjaOptions>(builder.Configuration.GetSection(WishNinjaOptions.SectionName));
|
||||
var appOptions = builder.Configuration.GetSection(WishNinjaOptions.SectionName).Get<WishNinjaOptions>() ?? new WishNinjaOptions();
|
||||
|
||||
var dataPath = Path.GetFullPath(appOptions.DataPath);
|
||||
Directory.CreateDirectory(dataPath);
|
||||
|
||||
// Connection string: explicit override wins, otherwise derive from the data path.
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? $"Data Source={Path.Combine(dataPath, "wishninja.db")};Cache=Shared";
|
||||
|
||||
// --- Blazor + auth state ---------------------------------------------------------------------
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddScoped<IdentityRedirectManager>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, IdentityRevalidatingAuthenticationStateProvider>();
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
||||
})
|
||||
.AddIdentityCookies();
|
||||
|
||||
// --- EF Core + Identity ----------------------------------------------------------------------
|
||||
// Factory for short-lived contexts (used by interactive Blazor components and app services),
|
||||
// plus a scoped context resolved from the factory for ASP.NET Core Identity's stores.
|
||||
builder.Services.AddDbContextFactory<ApplicationDbContext>(options => options.UseSqlite(connectionString));
|
||||
builder.Services.AddScoped<ApplicationDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ApplicationDbContext>>().CreateDbContext());
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.SignIn.RequireConfirmedAccount = true;
|
||||
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
|
||||
})
|
||||
.AddRoles<IdentityRole>()
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddSignInManager()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
// --- Data protection (persist keys so cookies survive container restarts) --------------------
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(dataPath, "keys")))
|
||||
.SetApplicationName("WishNinja");
|
||||
|
||||
// --- App services ----------------------------------------------------------------------------
|
||||
builder.Services.AddSingleton<EmailSender>();
|
||||
builder.Services.AddSingleton<IAppEmailSender>(sp => sp.GetRequiredService<EmailSender>());
|
||||
builder.Services.AddSingleton<IEmailSender<ApplicationUser>>(sp => sp.GetRequiredService<EmailSender>());
|
||||
builder.Services.AddHttpClient("image-fetch", c =>
|
||||
{
|
||||
c.Timeout = TimeSpan.FromSeconds(15);
|
||||
c.DefaultRequestHeaders.UserAgent.ParseAdd("WishNinja/1.0");
|
||||
});
|
||||
builder.Services.AddSingleton<ImageService>();
|
||||
builder.Services.AddScoped<WishlistService>();
|
||||
builder.Services.AddScoped<InviteService>();
|
||||
|
||||
// Respect reverse-proxy headers (Unraid + SWAG / NPM) so scheme/host are correct.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseMigrationsEndPoint();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
|
||||
// Serve uploaded item images from the data volume.
|
||||
var imageService = app.Services.GetRequiredService<ImageService>();
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(imageService.UploadsDirectory),
|
||||
RequestPath = "/uploads",
|
||||
});
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
// Add additional endpoints required by the Identity /Account Razor components.
|
||||
app.MapAdditionalIdentityEndpoints();
|
||||
|
||||
// Apply migrations and seed roles + bootstrap admin.
|
||||
await StartupInitializer.InitializeAsync(app.Services, app.Configuration,
|
||||
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Startup"));
|
||||
|
||||
app.Run();
|
||||
|
||||
// Exposed for integration testing.
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5283",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7025;http://localhost:5283",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
|
||||
namespace WishNinja.Services;
|
||||
|
||||
public static class AuthStateExtensions
|
||||
{
|
||||
/// <summary>Returns the current user's Identity id, or null if not authenticated.</summary>
|
||||
public static async Task<string?> GetUserIdAsync(this AuthenticationStateProvider provider)
|
||||
{
|
||||
var state = await provider.GetAuthenticationStateAsync();
|
||||
return state.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
}
|
||||
|
||||
public static async Task<bool> IsInRoleAsync(this AuthenticationStateProvider provider, string role)
|
||||
{
|
||||
var state = await provider.GetAuthenticationStateAsync();
|
||||
return state.User.IsInRole(role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using WishNinja.Configuration;
|
||||
using WishNinja.Data;
|
||||
|
||||
namespace WishNinja.Services;
|
||||
|
||||
/// <summary>
|
||||
/// MailKit-backed sender. Implements both <see cref="IAppEmailSender"/> (invites and ad-hoc mail)
|
||||
/// and Identity's <see cref="IEmailSender{TUser}"/> (confirmation/reset links). When no SMTP host
|
||||
/// is configured the message is logged instead of sent, so dev/first-run works without an email
|
||||
/// server.
|
||||
/// </summary>
|
||||
public class EmailSender(IOptions<WishNinjaOptions> options, ILogger<EmailSender> logger)
|
||||
: IAppEmailSender, IEmailSender<ApplicationUser>
|
||||
{
|
||||
private readonly SmtpOptions _smtp = options.Value.Smtp;
|
||||
|
||||
public async Task SendEmailAsync(string email, string subject, string htmlMessage)
|
||||
{
|
||||
if (!_smtp.IsConfigured)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SMTP not configured — email to {Email} not sent. Subject: {Subject}\n{Body}",
|
||||
email, subject, htmlMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(_smtp.FromName, _smtp.FromAddress));
|
||||
message.To.Add(MailboxAddress.Parse(email));
|
||||
message.Subject = subject;
|
||||
message.Body = new BodyBuilder { HtmlBody = htmlMessage }.ToMessageBody();
|
||||
|
||||
using var client = new SmtpClient();
|
||||
var socketOption = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
await client.ConnectAsync(_smtp.Host!, _smtp.Port, socketOption);
|
||||
if (!string.IsNullOrWhiteSpace(_smtp.User))
|
||||
{
|
||||
await client.AuthenticateAsync(_smtp.User, _smtp.Password ?? string.Empty);
|
||||
}
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(quit: true);
|
||||
logger.LogInformation("Sent email to {Email}: {Subject}", email, subject);
|
||||
}
|
||||
|
||||
// Identity IEmailSender<TUser> implementation -------------------------------------------------
|
||||
|
||||
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
|
||||
SendEmailAsync(email, "Confirm your WishNinja email",
|
||||
$"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
|
||||
|
||||
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
|
||||
SendEmailAsync(email, "Reset your WishNinja password",
|
||||
$"Reset your password by <a href='{resetLink}'>clicking here</a>. If you didn't request this, you can ignore this email.");
|
||||
|
||||
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
|
||||
SendEmailAsync(email, "Reset your WishNinja password",
|
||||
$"Your password reset code is: <strong>{resetCode}</strong>");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace WishNinja.Services;
|
||||
|
||||
/// <summary>Sends arbitrary HTML email (invites, notifications) independent of Identity.</summary>
|
||||
public interface IAppEmailSender
|
||||
{
|
||||
Task SendEmailAsync(string toEmail, string subject, string htmlMessage);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using WishNinja.Configuration;
|
||||
|
||||
namespace WishNinja.Services;
|
||||
|
||||
public record ImageSaveResult(bool Succeeded, string? FileName = null, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Saves uploaded item images (file uploads and clipboard-pasted blobs converge here) into the
|
||||
/// uploads directory under the data path. Validates content type and size; names files with GUIDs.
|
||||
/// </summary>
|
||||
public class ImageService
|
||||
{
|
||||
private static readonly Dictionary<string, string> AllowedTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["image/png"] = ".png",
|
||||
["image/jpeg"] = ".jpg",
|
||||
["image/webp"] = ".webp",
|
||||
["image/gif"] = ".gif",
|
||||
};
|
||||
|
||||
private readonly UploadOptions _uploads;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
public string UploadsDirectory { get; }
|
||||
|
||||
public ImageService(IOptions<WishNinjaOptions> options, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_uploads = options.Value.Uploads;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
UploadsDirectory = Path.GetFullPath(Path.Combine(options.Value.DataPath, "uploads"));
|
||||
Directory.CreateDirectory(UploadsDirectory);
|
||||
}
|
||||
|
||||
public bool IsAllowedContentType(string? contentType) =>
|
||||
contentType is not null && AllowedTypes.ContainsKey(contentType);
|
||||
|
||||
public long MaxBytes => _uploads.MaxBytes;
|
||||
|
||||
/// <summary>Validates and writes a stream to disk, returning the stored relative file name.</summary>
|
||||
public async Task<ImageSaveResult> SaveAsync(Stream stream, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
if (!AllowedTypes.TryGetValue(contentType, out var ext))
|
||||
return new ImageSaveResult(false, Error: "Unsupported image type. Use PNG, JPEG, WebP or GIF.");
|
||||
|
||||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||
var fullPath = Path.Combine(UploadsDirectory, fileName);
|
||||
|
||||
// Cap the write at MaxBytes + 1 so oversized uploads fail without buffering the whole thing.
|
||||
await using var output = File.Create(fullPath);
|
||||
var buffer = new byte[81920];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
total += read;
|
||||
if (total > _uploads.MaxBytes)
|
||||
{
|
||||
output.Close();
|
||||
File.Delete(fullPath);
|
||||
return new ImageSaveResult(false, Error: $"Image exceeds the {_uploads.MaxBytes / (1024 * 1024)} MB limit.");
|
||||
}
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
}
|
||||
|
||||
return new ImageSaveResult(true, FileName: fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a remote image and stores it locally (so the wishlist never depends on the
|
||||
/// external URL staying up). Validates scheme, content type and size.
|
||||
/// </summary>
|
||||
public async Task<ImageSaveResult> SaveFromUrlAsync(string url, CancellationToken ct = default)
|
||||
{
|
||||
if (!Uri.TryCreate(url?.Trim(), UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
return new ImageSaveResult(false, Error: "Enter a valid http(s) image URL.");
|
||||
|
||||
var client = _httpClientFactory.CreateClient("image-fetch");
|
||||
try
|
||||
{
|
||||
using var resp = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return new ImageSaveResult(false, Error: $"Couldn't fetch image (HTTP {(int)resp.StatusCode}).");
|
||||
|
||||
// Prefer the served content type; fall back to guessing from the URL's extension.
|
||||
var contentType = resp.Content.Headers.ContentType?.MediaType;
|
||||
if (!IsAllowedContentType(contentType))
|
||||
contentType = GuessContentTypeFromPath(uri.AbsolutePath);
|
||||
if (!IsAllowedContentType(contentType))
|
||||
return new ImageSaveResult(false, Error: "That URL didn't return a supported image (PNG, JPEG, WebP or GIF).");
|
||||
|
||||
if (resp.Content.Headers.ContentLength is long len && len > _uploads.MaxBytes)
|
||||
return new ImageSaveResult(false, Error: $"Image exceeds the {_uploads.MaxBytes / (1024 * 1024)} MB limit.");
|
||||
|
||||
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||||
return await SaveAsync(stream, contentType!, ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new ImageSaveResult(false, Error: "Couldn't download an image from that URL.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GuessContentTypeFromPath(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".webp" => "image/webp",
|
||||
".gif" => "image/gif",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public void Delete(string? fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return;
|
||||
// Guard against path traversal — only operate on a bare file name.
|
||||
var safe = Path.GetFileName(fileName);
|
||||
var path = Path.Combine(UploadsDirectory, safe);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WishNinja.Configuration;
|
||||
using WishNinja.Data;
|
||||
using WishNinja.Data.Entities;
|
||||
|
||||
namespace WishNinja.Services;
|
||||
|
||||
public record InviteResult(bool Succeeded, string? Error = null, string? AcceptUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Manages the admin-invite-only onboarding flow: an admin creates an invite, the recipient
|
||||
/// follows an emailed single-use link to set a display name + password, which creates the account.
|
||||
/// </summary>
|
||||
public class InviteService(
|
||||
IDbContextFactory<ApplicationDbContext> dbFactory,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAppEmailSender emailSender,
|
||||
IOptions<WishNinjaOptions> options,
|
||||
ILogger<InviteService> logger)
|
||||
{
|
||||
private readonly WishNinjaOptions _options = options.Value;
|
||||
|
||||
/// <summary>Creates an invite, emails the accept link, and returns the link (for admin display).</summary>
|
||||
public async Task<InviteResult> CreateInviteAsync(string email, string role, string invitedByUserId)
|
||||
{
|
||||
email = email.Trim();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return new InviteResult(false, "Email is required.");
|
||||
|
||||
if (await userManager.FindByEmailAsync(email) is not null)
|
||||
return new InviteResult(false, "A user with that email already exists.");
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
|
||||
// Supersede any outstanding, unaccepted invites for the same email.
|
||||
var stale = await db.Invites
|
||||
.Where(i => i.Email == email && i.AcceptedAt == null)
|
||||
.ToListAsync();
|
||||
db.Invites.RemoveRange(stale);
|
||||
|
||||
var rawToken = GenerateToken();
|
||||
var invite = new Invite
|
||||
{
|
||||
Email = email,
|
||||
TokenHash = HashToken(rawToken),
|
||||
Role = role,
|
||||
InvitedByUserId = invitedByUserId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(_options.Invites.ExpiryHours),
|
||||
};
|
||||
db.Invites.Add(invite);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var acceptUrl = $"{_options.BaseUrl.TrimEnd('/')}/account/accept-invite?token={Uri.EscapeDataString(rawToken)}";
|
||||
await emailSender.SendEmailAsync(email, "You're invited to WishNinja",
|
||||
$"You've been invited to join WishNinja. <a href='{acceptUrl}'>Click here to set up your account</a>. This link expires in {_options.Invites.ExpiryHours / 24} days.");
|
||||
|
||||
logger.LogInformation("Invite created for {Email} by {Admin}", email, invitedByUserId);
|
||||
return new InviteResult(true, AcceptUrl: acceptUrl);
|
||||
}
|
||||
|
||||
/// <summary>Returns the matching pending, unexpired invite for a raw token, or null.</summary>
|
||||
public async Task<Invite?> GetValidInviteAsync(string rawToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken)) return null;
|
||||
var hash = HashToken(rawToken);
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var invite = await db.Invites.FirstOrDefaultAsync(i => i.TokenHash == hash);
|
||||
if (invite is null || invite.IsAccepted || invite.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
return null;
|
||||
return invite;
|
||||
}
|
||||
|
||||
/// <summary>Redeems an invite, creating the user account. Single-use.</summary>
|
||||
public async Task<IdentityResult> AcceptInviteAsync(string rawToken, string displayName, string password)
|
||||
{
|
||||
var invite = await GetValidInviteAsync(rawToken);
|
||||
if (invite is null)
|
||||
return IdentityResult.Failed(new IdentityError { Description = "This invite link is invalid or has expired." });
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = invite.Email,
|
||||
Email = invite.Email,
|
||||
EmailConfirmed = true, // ownership proven by following the emailed link
|
||||
DisplayName = string.IsNullOrWhiteSpace(displayName) ? invite.Email : displayName.Trim(),
|
||||
};
|
||||
|
||||
var create = await userManager.CreateAsync(user, password);
|
||||
if (!create.Succeeded)
|
||||
return create;
|
||||
|
||||
await userManager.AddToRoleAsync(user, invite.Role);
|
||||
|
||||
// Mark single-use within a fresh context (the one in GetValidInviteAsync is disposed).
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
await db.Invites.Where(i => i.Id == invite.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(i => i.AcceptedAt, DateTimeOffset.UtcNow));
|
||||
|
||||
logger.LogInformation("Invite accepted, user created: {Email}", invite.Email);
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
private static string GenerateToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||
}
|
||||
|
||||
private static string HashToken(string rawToken)
|
||||
{
|
||||
var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rawToken));
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WishNinja.Data;
|
||||
|
||||
namespace WishNinja.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs once at startup: applies EF migrations and seeds the Admin/User roles plus a bootstrap
|
||||
/// admin account from SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD so there is always a way in.
|
||||
/// </summary>
|
||||
public static class StartupInitializer
|
||||
{
|
||||
public const string AdminRole = "Admin";
|
||||
public const string UserRole = "User";
|
||||
|
||||
public static async Task InitializeAsync(IServiceProvider services, IConfiguration config, ILogger logger)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var sp = scope.ServiceProvider;
|
||||
|
||||
var db = sp.GetRequiredService<ApplicationDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var roleManager = sp.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
foreach (var role in new[] { AdminRole, UserRole })
|
||||
{
|
||||
if (!await roleManager.RoleExistsAsync(role))
|
||||
await roleManager.CreateAsync(new IdentityRole(role));
|
||||
}
|
||||
|
||||
var adminEmail = config["SEED_ADMIN_EMAIL"];
|
||||
var adminPassword = config["SEED_ADMIN_PASSWORD"];
|
||||
if (string.IsNullOrWhiteSpace(adminEmail) || string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
logger.LogWarning("SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD not set — no bootstrap admin created.");
|
||||
return;
|
||||
}
|
||||
|
||||
var userManager = sp.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var existing = await userManager.FindByEmailAsync(adminEmail);
|
||||
if (existing is null)
|
||||
{
|
||||
var admin = new ApplicationUser
|
||||
{
|
||||
UserName = adminEmail,
|
||||
Email = adminEmail,
|
||||
EmailConfirmed = true,
|
||||
DisplayName = "Administrator",
|
||||
};
|
||||
var result = await userManager.CreateAsync(admin, adminPassword);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await userManager.AddToRoleAsync(admin, AdminRole);
|
||||
logger.LogInformation("Created bootstrap admin {Email}", adminEmail);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Failed to create bootstrap admin: {Errors}",
|
||||
string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
else if (!await userManager.IsInRoleAsync(existing, AdminRole))
|
||||
{
|
||||
await userManager.AddToRoleAsync(existing, AdminRole);
|
||||
logger.LogInformation("Granted Admin role to existing user {Email}", adminEmail);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WishNinja.Data;
|
||||
using WishNinja.Data.Entities;
|
||||
|
||||
namespace WishNinja.Services;
|
||||
|
||||
public record ClaimResult(bool Succeeded, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Read/write operations for wishlists, items and claims. The defining rule lives here: a wishlist
|
||||
/// owner viewing their own list never receives claim data. This is enforced when building views and
|
||||
/// blocked again on every claim mutation, so the surprise cannot leak through the UI or an API call.
|
||||
/// </summary>
|
||||
public class WishlistService(IDbContextFactory<ApplicationDbContext> dbFactory)
|
||||
{
|
||||
// --- Visibility ----------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Lists owned by the user (for "My Wishlists").</summary>
|
||||
public async Task<List<Wishlist>> GetOwnedAsync(string userId)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
return await db.Wishlists
|
||||
.Where(w => w.OwnerId == userId)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>Lists the user can view but does not own (others' shared lists).</summary>
|
||||
public async Task<List<Wishlist>> GetSharedWithAsync(string userId)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
return await db.Wishlists
|
||||
.Include(w => w.Owner)
|
||||
.Where(w => w.OwnerId != userId && !w.IsArchived &&
|
||||
(w.Visibility == WishlistVisibility.AllMembers ||
|
||||
w.Shares.Any(s => s.UserId == userId)))
|
||||
.OrderBy(w => w.Title)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> CanViewAsync(int wishlistId, string userId)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var w = await db.Wishlists.Include(x => x.Shares).FirstOrDefaultAsync(x => x.Id == wishlistId);
|
||||
return w is not null && CanView(w, userId);
|
||||
}
|
||||
|
||||
private static bool CanView(Wishlist w, string userId) =>
|
||||
w.OwnerId == userId ||
|
||||
w.Visibility == WishlistVisibility.AllMembers ||
|
||||
w.Shares.Any(s => s.UserId == userId);
|
||||
|
||||
// --- Detail view (owner-hidden claims) -----------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-viewer detail. Returns null if the user may not view the list. Claim data is
|
||||
/// only loaded/returned when the viewer is NOT the owner.
|
||||
/// </summary>
|
||||
public async Task<WishlistDetailView?> GetDetailAsync(int wishlistId, string userId)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var wishlist = await db.Wishlists
|
||||
.Include(w => w.Shares)
|
||||
.Include(w => w.Items.OrderBy(i => i.Priority).ThenBy(i => i.SortOrder).ThenBy(i => i.Id))
|
||||
.FirstOrDefaultAsync(w => w.Id == wishlistId);
|
||||
|
||||
if (wishlist is null || !CanView(wishlist, userId))
|
||||
return null;
|
||||
|
||||
var isOwner = wishlist.OwnerId == userId;
|
||||
|
||||
if (isOwner)
|
||||
{
|
||||
// Owner: never query the Claims table at all.
|
||||
var ownerItems = wishlist.Items
|
||||
.Select(i => new WishlistItemView(i, true, 0, false, 0, Array.Empty<ClaimInfo>()))
|
||||
.ToList();
|
||||
return new WishlistDetailView(wishlist, true, ownerItems);
|
||||
}
|
||||
|
||||
var itemIds = wishlist.Items.Select(i => i.Id).ToList();
|
||||
var claims = await db.Claims
|
||||
.Include(c => c.ClaimedByUser)
|
||||
.Where(c => itemIds.Contains(c.WishlistItemId))
|
||||
.ToListAsync();
|
||||
|
||||
var byItem = claims.ToLookup(c => c.WishlistItemId);
|
||||
var items = wishlist.Items.Select(i =>
|
||||
{
|
||||
var itemClaims = byItem[i.Id].ToList();
|
||||
var viewerClaim = itemClaims.FirstOrDefault(c => c.ClaimedByUserId == userId);
|
||||
var others = itemClaims
|
||||
.Where(c => c.ClaimedByUserId != userId)
|
||||
.Select(c => new ClaimInfo(c.Id, c.ClaimedByUserId,
|
||||
c.ClaimedByUser?.DisplayName ?? "Someone", c.Quantity, c.Note))
|
||||
.ToList();
|
||||
return new WishlistItemView(
|
||||
i,
|
||||
IsViewerOwner: false,
|
||||
ClaimedQuantity: itemClaims.Sum(c => c.Quantity),
|
||||
ClaimedByViewer: viewerClaim is not null,
|
||||
ViewerClaimId: viewerClaim?.Id ?? 0,
|
||||
OtherClaims: others);
|
||||
}).ToList();
|
||||
|
||||
return new WishlistDetailView(wishlist, false, items);
|
||||
}
|
||||
|
||||
// --- Claim mutations -----------------------------------------------------------------------
|
||||
|
||||
public async Task<ClaimResult> ClaimAsync(int itemId, string userId, int quantity, string? note)
|
||||
{
|
||||
if (quantity < 1) quantity = 1;
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var item = await db.WishlistItems
|
||||
.Include(i => i.Wishlist!).ThenInclude(w => w.Shares)
|
||||
.Include(i => i.Claims)
|
||||
.FirstOrDefaultAsync(i => i.Id == itemId);
|
||||
|
||||
if (item is null) return new ClaimResult(false, "Item not found.");
|
||||
if (item.Wishlist!.OwnerId == userId)
|
||||
return new ClaimResult(false, "You can't claim items on your own wishlist.");
|
||||
if (!CanView(item.Wishlist, userId))
|
||||
return new ClaimResult(false, "You don't have access to this wishlist.");
|
||||
|
||||
var alreadyClaimed = item.Claims.Sum(c => c.Quantity);
|
||||
var mine = item.Claims.FirstOrDefault(c => c.ClaimedByUserId == userId);
|
||||
var othersClaimed = alreadyClaimed - (mine?.Quantity ?? 0);
|
||||
|
||||
if (othersClaimed + quantity > item.Quantity)
|
||||
return new ClaimResult(false, $"Only {item.Quantity - othersClaimed} left to claim.");
|
||||
|
||||
if (mine is not null)
|
||||
{
|
||||
mine.Quantity = quantity;
|
||||
mine.Note = note;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.Claims.Add(new Claim
|
||||
{
|
||||
WishlistItemId = itemId,
|
||||
ClaimedByUserId = userId,
|
||||
Quantity = quantity,
|
||||
Note = note,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return new ClaimResult(true);
|
||||
}
|
||||
|
||||
public async Task<ClaimResult> UnclaimAsync(int itemId, string userId)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
var item = await db.WishlistItems.Include(i => i.Wishlist).FirstOrDefaultAsync(i => i.Id == itemId);
|
||||
if (item is null) return new ClaimResult(false, "Item not found.");
|
||||
if (item.Wishlist!.OwnerId == userId)
|
||||
return new ClaimResult(false, "Owners have no claims to remove.");
|
||||
|
||||
var mine = await db.Claims.FirstOrDefaultAsync(c => c.WishlistItemId == itemId && c.ClaimedByUserId == userId);
|
||||
if (mine is null) return new ClaimResult(false, "You haven't claimed this item.");
|
||||
|
||||
db.Claims.Remove(mine);
|
||||
await db.SaveChangesAsync();
|
||||
return new ClaimResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using WishNinja.Data.Entities;
|
||||
|
||||
namespace WishNinja.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A wishlist prepared for a specific viewer. When <see cref="IsViewerOwner"/> is true, every
|
||||
/// item's claim fields are blanked — the owner can never learn what has been claimed.
|
||||
/// </summary>
|
||||
public record WishlistDetailView(
|
||||
Wishlist Wishlist,
|
||||
bool IsViewerOwner,
|
||||
IReadOnlyList<WishlistItemView> Items);
|
||||
|
||||
public record WishlistItemView(
|
||||
WishlistItem Item,
|
||||
bool IsViewerOwner,
|
||||
int ClaimedQuantity,
|
||||
bool ClaimedByViewer,
|
||||
int ViewerClaimId,
|
||||
IReadOnlyList<ClaimInfo> OtherClaims)
|
||||
{
|
||||
/// <summary>Remaining unclaimed quantity. Always equals full quantity for the owner (claims hidden).</summary>
|
||||
public int RemainingQuantity => Math.Max(0, Item.Quantity - ClaimedQuantity);
|
||||
|
||||
public bool IsFullyClaimed => !IsViewerOwner && RemainingQuantity == 0;
|
||||
}
|
||||
|
||||
/// <summary>A single claim as shown to non-owner viewers.</summary>
|
||||
public record ClaimInfo(int ClaimId, string ClaimedByUserId, string ClaimedByDisplayName, int Quantity, string? Note);
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>aspnet-WishNinja-38d6c08d-1e21-4ccd-9aa0-b5b50da835a6</UserSecretsId>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"WishNinja": {
|
||||
"BaseUrl": "https://localhost:7777",
|
||||
"DataPath": "Data",
|
||||
"Smtp": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"UseStartTls": true,
|
||||
"User": "",
|
||||
"Password": "",
|
||||
"FromAddress": "wishninja@localhost",
|
||||
"FromName": "WishNinja"
|
||||
},
|
||||
"Uploads": {
|
||||
"MaxBytes": 5242880
|
||||
},
|
||||
"Invites": {
|
||||
"ExpiryHours": 168
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 |
@@ -0,0 +1,39 @@
|
||||
// Captures images pasted from the clipboard inside the item editor and forwards the bytes to .NET.
|
||||
// Used by Components/Wishlists/ItemEditor.razor.
|
||||
|
||||
export function registerPaste(zoneId, dotNetRef) {
|
||||
const zone = document.getElementById(zoneId);
|
||||
if (!zone) return null;
|
||||
|
||||
const readAsBase64 = (file) => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result || '';
|
||||
const comma = result.indexOf(',');
|
||||
resolve(comma >= 0 ? result.substring(comma + 1) : result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const handler = async (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (item.type && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
e.preventDefault();
|
||||
const base64 = await readAsBase64(file);
|
||||
await dotNetRef.invokeMethodAsync('OnImagePasted', base64, item.type);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen on the document so a paste anywhere while the editor is open is captured.
|
||||
document.addEventListener('paste', handler);
|
||||
return {
|
||||
dispose: () => document.removeEventListener('paste', handler)
|
||||
};
|
||||
}
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user