add password reset
This commit is contained in:
parent
0131a10b09
commit
ad3ac3185c
|
|
@ -332,7 +332,7 @@ NavMenu.razor:
|
||||||
| ✓ | A7 | Foundation | DI Registration | 2 DI-Dateien ändern |
|
| ✓ | A7 | Foundation | DI Registration | 2 DI-Dateien ändern |
|
||||||
| ✓ | B1 | User/Account | UserService erweitern | 1 Service, 1 Interface, 1 DTO |
|
| ✓ | B1 | User/Account | UserService erweitern | 1 Service, 1 Interface, 1 DTO |
|
||||||
| ✓ | B2 | User/Account | Register Page | 1 Razor |
|
| ✓ | B2 | User/Account | Register Page | 1 Razor |
|
||||||
| ☐ | B3 | User/Account | Password Reset Pages | 2 Razor |
|
| ✓ | B3 | User/Account | Password Reset Pages | 2 Razor |
|
||||||
| ☐ | B4 | User/Account | Profile Page | 1 Razor |
|
| ☐ | B4 | User/Account | Profile Page | 1 Razor |
|
||||||
| ☐ | B5 | User/Account | Admin Users Page | 1 Razor |
|
| ☐ | B5 | User/Account | Admin Users Page | 1 Razor |
|
||||||
| ☐ | **C1** | **Clubs** | **ClubState Fluxor** | **4 State-Dateien** |
|
| ☐ | **C1** | **Clubs** | **ClubState Fluxor** | **4 State-Dateien** |
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,101 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Koogle.Application.DTOs
|
namespace Koogle.Application.DTOs
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Form DTO for user login.
|
||||||
|
/// </summary>
|
||||||
public record LoginDto
|
public record LoginDto
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Email of the user requesting login.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[EmailAddress]
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Password { get; set; } = string.Empty;
|
public string Password { get; set; } = string.Empty;
|
||||||
public string ClubName { get; set; } = string.Empty;
|
public string ClubName { get; set; } = string.Empty;
|
||||||
public bool RememberMe { get; set; }
|
public bool RememberMe { get; set; }
|
||||||
public string? ReturnUrl { get; set; }
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Form DTO for password reset with confirmation field.
|
||||||
|
/// </summary>
|
||||||
|
public class ResetPasswordFormDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Email of the user requesting password reset.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[EmailAddress]
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public string Token { get; set; } = "";
|
||||||
|
public string NewPassword { get; set; } = "";
|
||||||
|
public string ConfirmPassword { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for user registration.
|
||||||
|
/// </summary>
|
||||||
|
public class RegisterUserDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Email address (also used as username).
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[EmailAddress]
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Password for the new account.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[MinLength(6)]
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display name for the user profile.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional club name to assign user to during registration.
|
||||||
|
/// </summary>
|
||||||
|
public string? ClubName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for password reset request.
|
||||||
|
/// </summary>
|
||||||
|
public class ResetPasswordDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Email of the user requesting password reset.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[EmailAddress]
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Password reset token received via email.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string Token { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// New password.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[MinLength(6)]
|
||||||
|
public string NewPassword { get; set; } = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,62 +70,6 @@ namespace Koogle.Application.DTOs
|
||||||
Koogle.Domain.Entities.UserProfile Profile,
|
Koogle.Domain.Entities.UserProfile Profile,
|
||||||
Koogle.Infrastructure.Identity.ApplicationUser Identity);
|
Koogle.Infrastructure.Identity.ApplicationUser Identity);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for user registration.
|
|
||||||
/// </summary>
|
|
||||||
public class RegisterUserDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Email address (also used as username).
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
public string Email { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Password for the new account.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[MinLength(6)]
|
|
||||||
public string Password { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Display name for the user profile.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string DisplayName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional club name to assign user to during registration.
|
|
||||||
/// </summary>
|
|
||||||
public string? ClubName { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for password reset request.
|
|
||||||
/// </summary>
|
|
||||||
public class ResetPasswordDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Email of the user requesting password reset.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
public string Email { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Password reset token received via email.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string Token { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// New password.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[MinLength(6)]
|
|
||||||
public string NewPassword { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DTO for updating user profile.
|
/// DTO for updating user profile.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
@page "/account/forgot-password"
|
||||||
|
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
|
||||||
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
|
|
||||||
|
<form method="post" action="/auth/forgot-password">
|
||||||
|
<!-- Hidden Fields -->
|
||||||
|
<input type="hidden" name="__RequestVerificationToken" value="@_antiToken" />
|
||||||
|
|
||||||
|
<MudPaper Class="pa-6" Elevation="4" MaxWidth="400px">
|
||||||
|
@if (_success)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Success" Class="mb-3">
|
||||||
|
Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_error))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mb-3">@_error</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-4">
|
||||||
|
Passwort vergessen
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-4">
|
||||||
|
Gib deine E-Mail-Adresse ein. Wenn ein Konto existiert, senden wir dir einen Link zum Zuruecksetzen.
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudTextField T="string"
|
||||||
|
Name="Email"
|
||||||
|
Label="E-Mail"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Required="true"
|
||||||
|
InputType="InputType.Email"
|
||||||
|
AutoComplete="email"
|
||||||
|
Class="mb-4" />
|
||||||
|
|
||||||
|
<MudButton
|
||||||
|
ButtonType="ButtonType.Submit"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
FullWidth="true"
|
||||||
|
Class="mb-3">
|
||||||
|
Reset-Link anfordern
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
|
<MudLink Href="/account/login" Typo="Typo.body2">
|
||||||
|
Zurueck zur Anmeldung
|
||||||
|
</MudLink>
|
||||||
|
|
||||||
|
</MudPaper>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string? _error;
|
||||||
|
private bool _success;
|
||||||
|
private string _antiToken = "";
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
|
||||||
|
if (query.TryGetValue("error", out var err))
|
||||||
|
{
|
||||||
|
_error = err.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.TryGetValue("success", out _))
|
||||||
|
{
|
||||||
|
_success = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antiforgery Token generieren
|
||||||
|
var http = HttpContextAccessor.HttpContext!;
|
||||||
|
var tokens = Antiforgery.GetAndStoreTokens(http);
|
||||||
|
_antiToken = tokens.RequestToken!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,9 +68,14 @@
|
||||||
Anmelden
|
Anmelden
|
||||||
</MudButton>
|
</MudButton>
|
||||||
|
|
||||||
<MudLink Href="/account/register" Typo="Typo.body2">
|
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mt-2">
|
||||||
Noch kein Konto? Registrieren
|
<MudLink Href="/account/register" Typo="Typo.body2">
|
||||||
</MudLink>
|
Noch kein Konto? Registrieren
|
||||||
|
</MudLink>
|
||||||
|
<MudLink Href="/account/forgot-password" Typo="Typo.body2">
|
||||||
|
Passwort vergessen?
|
||||||
|
</MudLink>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
@page "/account/reset-password"
|
||||||
|
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
|
||||||
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
|
|
||||||
|
@if (_success)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-6" Elevation="4" MaxWidth="400px">
|
||||||
|
<MudAlert Severity="Severity.Success" Class="mb-3">
|
||||||
|
Passwort wurde erfolgreich zurueckgesetzt.
|
||||||
|
</MudAlert>
|
||||||
|
<MudButton Href="/account/login" Variant="Variant.Filled" Color="Color.Primary" FullWidth="true">
|
||||||
|
Zur Anmeldung
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else if (_invalidToken)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-6" Elevation="4" MaxWidth="400px">
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mb-3">
|
||||||
|
Ungueltiger oder abgelaufener Reset-Link.
|
||||||
|
</MudAlert>
|
||||||
|
<MudButton Href="/account/forgot-password" Variant="Variant.Filled" Color="Color.Primary" FullWidth="true">
|
||||||
|
Neuen Link anfordern
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<form method="post" action="/auth/reset-password">
|
||||||
|
<!-- Hidden Fields -->
|
||||||
|
<input type="hidden" name="__RequestVerificationToken" value="@_antiToken" />
|
||||||
|
<input type="hidden" name="Email" value="@_email" />
|
||||||
|
<input type="hidden" name="Token" value="@_token" />
|
||||||
|
|
||||||
|
<MudPaper Class="pa-6" Elevation="4" MaxWidth="400px">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_error))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Class="mb-3">@_error</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-4">
|
||||||
|
Neues Passwort setzen
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-4">
|
||||||
|
E-Mail: @_email
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudTextField T="string"
|
||||||
|
Name="NewPassword"
|
||||||
|
Label="Neues Passwort"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Required="true"
|
||||||
|
InputType="InputType.Password"
|
||||||
|
AutoComplete="new-password"
|
||||||
|
HelperText="Mindestens 6 Zeichen"
|
||||||
|
Class="mb-3" />
|
||||||
|
|
||||||
|
<MudTextField T="string"
|
||||||
|
Name="ConfirmPassword"
|
||||||
|
Label="Passwort bestaetigen"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Required="true"
|
||||||
|
InputType="InputType.Password"
|
||||||
|
AutoComplete="new-password"
|
||||||
|
Class="mb-4" />
|
||||||
|
|
||||||
|
<MudButton
|
||||||
|
ButtonType="ButtonType.Submit"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
FullWidth="true"
|
||||||
|
Class="mb-3">
|
||||||
|
Passwort zuruecksetzen
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
|
<MudLink Href="/account/login" Typo="Typo.body2">
|
||||||
|
Zurueck zur Anmeldung
|
||||||
|
</MudLink>
|
||||||
|
|
||||||
|
</MudPaper>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string? _error;
|
||||||
|
private bool _success;
|
||||||
|
private bool _invalidToken;
|
||||||
|
private string _email = "";
|
||||||
|
private string _token = "";
|
||||||
|
private string _antiToken = "";
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
|
||||||
|
if (query.TryGetValue("email", out var email))
|
||||||
|
{
|
||||||
|
_email = email.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.TryGetValue("token", out var token))
|
||||||
|
{
|
||||||
|
_token = token.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.TryGetValue("success", out _))
|
||||||
|
{
|
||||||
|
_success = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.TryGetValue("error", out var err))
|
||||||
|
{
|
||||||
|
var errorStr = err.ToString();
|
||||||
|
if (errorStr == "InvalidToken")
|
||||||
|
{
|
||||||
|
_invalidToken = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_error = MapErrorCodes(errorStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing required params
|
||||||
|
if (string.IsNullOrWhiteSpace(_email) || string.IsNullOrWhiteSpace(_token))
|
||||||
|
{
|
||||||
|
if (!_success)
|
||||||
|
{
|
||||||
|
_invalidToken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antiforgery Token generieren
|
||||||
|
var http = HttpContextAccessor.HttpContext!;
|
||||||
|
var tokens = Antiforgery.GetAndStoreTokens(http);
|
||||||
|
_antiToken = tokens.RequestToken!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MapErrorCodes(string errorCodes)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(errorCodes))
|
||||||
|
return "Passwort-Reset fehlgeschlagen";
|
||||||
|
|
||||||
|
var codes = errorCodes.Split(',');
|
||||||
|
var messages = new List<string>();
|
||||||
|
|
||||||
|
foreach (var code in codes)
|
||||||
|
{
|
||||||
|
var msg = code.Trim() switch
|
||||||
|
{
|
||||||
|
"InvalidToken" => "Ungueltiger oder abgelaufener Token",
|
||||||
|
"PasswordTooShort" => "Passwort zu kurz",
|
||||||
|
"PasswordRequiresNonAlphanumeric" => "Passwort erfordert Sonderzeichen",
|
||||||
|
"PasswordRequiresDigit" => "Passwort erfordert Zahl",
|
||||||
|
"PasswordRequiresUpper" => "Passwort erfordert Grossbuchstaben",
|
||||||
|
"PasswordRequiresLower" => "Passwort erfordert Kleinbuchstaben",
|
||||||
|
"PasswordMismatch" => "Passwoerter stimmen nicht ueberein",
|
||||||
|
"UserNotFound" => "Benutzer nicht gefunden",
|
||||||
|
_ => code
|
||||||
|
};
|
||||||
|
messages.Add(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(", ", messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,5 +70,57 @@ namespace Koogle.Web.Controllers
|
||||||
return LocalRedirect($"/account/register?error={errors}");
|
return LocalRedirect($"/account/register?error={errors}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles password reset request (forgot password).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("forgot-password")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ForgotPassword([FromForm] string email)
|
||||||
|
{
|
||||||
|
// Always show success to prevent email enumeration
|
||||||
|
var token = await _userService.RequestPasswordResetAsync(email);
|
||||||
|
|
||||||
|
if (token != null)
|
||||||
|
{
|
||||||
|
// TODO: Send email with reset link
|
||||||
|
// For now, log the token for development purposes
|
||||||
|
var resetUrl = $"/account/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}";
|
||||||
|
Console.WriteLine($"[DEV] Password reset link: {resetUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocalRedirect("/account/forgot-password?success=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles password reset with token.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("reset-password")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ResetPassword([FromForm] ResetPasswordFormDto input)
|
||||||
|
{
|
||||||
|
// Validate password confirmation
|
||||||
|
if (input.NewPassword != input.ConfirmPassword)
|
||||||
|
{
|
||||||
|
return LocalRedirect($"/account/reset-password?email={Uri.EscapeDataString(input.Email)}&token={Uri.EscapeDataString(input.Token)}&error=PasswordMismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
var dto = new ResetPasswordDto
|
||||||
|
{
|
||||||
|
Email = input.Email,
|
||||||
|
Token = input.Token,
|
||||||
|
NewPassword = input.NewPassword
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _userService.ResetPasswordAsync(dto);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
return LocalRedirect("/account/reset-password?success=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors = string.Join(",", result.Errors.Select(e => e.Code));
|
||||||
|
return LocalRedirect($"/account/reset-password?email={Uri.EscapeDataString(input.Email)}&token={Uri.EscapeDataString(input.Token)}&error={errors}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue