feat(G8): add invite link handling for club membership

This commit is contained in:
beo3000 2025-12-25 21:17:00 +01:00
parent 637a3b120c
commit 0911236a0d
5 changed files with 244 additions and 1 deletions

View File

@ -71,6 +71,11 @@ namespace Koogle.Application.DTOs
/// Optional club name to assign user to during registration.
/// </summary>
public string? ClubName { get; set; }
/// <summary>
/// Optional invitation token for club membership during registration.
/// </summary>
public string? InviteToken { get; set; }
}
/// <summary>

View File

@ -14,6 +14,12 @@
<input type="hidden" name="__RequestVerificationToken" value="@_antiToken" />
<MudPaper Class="pa-6" Elevation="4" MaxWidth="400px">
@if (!string.IsNullOrWhiteSpace(_inviteToken))
{
<MudAlert Severity="Severity.Info" Class="mb-3">
Bitte melde dich an, um die Club-Einladung anzunehmen.
</MudAlert>
}
@if (_registered)
{
<MudAlert Severity="Severity.Success" Class="mb-3">Registrierung erfolgreich! Bitte anmelden.</MudAlert>
@ -86,6 +92,7 @@
private string? _error;
private string _antiToken = "";
private bool _registered;
private string? _inviteToken;
protected override void OnInitialized()
{
@ -101,6 +108,12 @@
{
_registered = true;
}
if (query.TryGetValue("invite", out var invite))
{
_inviteToken = invite.ToString();
// After login, redirect to join by invite
_returnUrl = $"/club/join/{_inviteToken}";
}
// Antiforgery Token generieren (klassischer MVC Token)
var http = HttpContextAccessor.HttpContext!;

View File

@ -9,8 +9,18 @@
<form method="post" action="/auth/register">
<!-- Hidden Fields -->
<input type="hidden" name="__RequestVerificationToken" value="@_antiToken" />
@if (!string.IsNullOrWhiteSpace(_inviteToken))
{
<input type="hidden" name="InviteToken" value="@_inviteToken" />
}
<MudPaper Class="pa-6" Elevation="4" MaxWidth="400px">
@if (!string.IsNullOrWhiteSpace(_inviteToken))
{
<MudAlert Severity="Severity.Info" Class="mb-3">
Du wurdest zu einem Club eingeladen. Registriere dich, um beizutreten.
</MudAlert>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<MudAlert Severity="Severity.Error" Class="mb-3">@_error</MudAlert>
@ -75,6 +85,7 @@
@code {
private string? _error;
private string? _inviteToken;
private string _antiToken = "";
protected override void OnInitialized()
@ -87,6 +98,11 @@
_error = MapErrorCodes(err.ToString());
}
if (query.TryGetValue("invite", out var invite))
{
_inviteToken = invite.ToString();
}
// Antiforgery Token generieren
var http = HttpContextAccessor.HttpContext!;
var tokens = Antiforgery.GetAndStoreTokens(http);

View File

@ -0,0 +1,201 @@
@page "/club/join/{Token}"
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Microsoft.AspNetCore.Components.Authorization
@inject IClubService ClubService
@inject IUserService UserService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
<PageTitle>Club beitreten</PageTitle>
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-4">
<MudText Typo="Typo.h4" Class="mb-4">Einladung zum Club</MudText>
@if (_isLoading)
{
<MudProgressCircular Indeterminate="true" />
}
else if (_invalidToken)
{
<MudPaper Class="pa-6" Elevation="2">
<div class="d-flex flex-column align-center text-center">
<MudIcon Icon="@Icons.Material.Filled.LinkOff" Size="Size.Large" Color="Color.Error" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-2">Ungueltige Einladung</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
@_errorMessage
</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/account/join-club">
Club manuell suchen
</MudButton>
</div>
</MudPaper>
}
else if (_requestSent)
{
<MudPaper Class="pa-6" Elevation="2">
<div class="d-flex flex-column align-center text-center">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Size="Size.Large" Color="Color.Success" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-2">Beitrittsantrag gesendet!</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
Dein Antrag fuer <strong>@_invitation?.ClubName</strong> wurde gesendet.
Ein Admin wird ihn pruefen.
</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/dashboard">
Zum Dashboard
</MudButton>
</div>
</MudPaper>
}
else if (_invitation is not null)
{
<MudPaper Class="pa-6" Elevation="2">
<div class="d-flex flex-column align-center text-center">
<MudIcon Icon="@Icons.Material.Filled.GroupAdd" Size="Size.Large" Color="Color.Primary" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-2">@_invitation.ClubName</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-4">
Du wurdest eingeladen, diesem Club beizutreten.
</MudText>
@if (!string.IsNullOrEmpty(_error))
{
<MudAlert Severity="Severity.Error" Class="mb-4">@_error</MudAlert>
}
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SubmitRequest"
Disabled="_isSubmitting"
StartIcon="@Icons.Material.Filled.PersonAdd">
@if (_isSubmitting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Beitreten
</MudButton>
</div>
</MudPaper>
}
</MudContainer>
@code {
[Parameter]
public string Token { get; set; } = "";
private ClubInvitationDto? _invitation;
private UserDto? _currentUser;
private bool _isLoading = true;
private bool _invalidToken;
private bool _requestSent;
private bool _isSubmitting;
private string? _error;
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
await ValidateAndProcess();
}
private async Task ValidateAndProcess()
{
_isLoading = true;
try
{
// Check if user is authenticated
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
if (!isAuthenticated)
{
// Redirect to register with token
NavigationManager.NavigateTo($"/account/register?invite={Uri.EscapeDataString(Token)}");
return;
}
// Validate token
_invitation = await ClubService.GetInvitationByTokenAsync(Token);
if (_invitation is null)
{
_invalidToken = true;
_errorMessage = "Diese Einladung existiert nicht oder wurde geloescht.";
return;
}
if (!_invitation.IsValid)
{
_invalidToken = true;
_errorMessage = "Diese Einladung ist abgelaufen oder wurde bereits zu oft verwendet.";
return;
}
// Load current user
_currentUser = await UserService.GetCurrentUserAsync();
if (_currentUser is null)
{
_invalidToken = true;
_errorMessage = "Benutzerprofil konnte nicht geladen werden.";
return;
}
// Check if already member
var existingMembership = _currentUser.ClubMemberships
.FirstOrDefault(m => m.ClubId == _invitation.ClubId);
if (existingMembership is not null)
{
_invalidToken = true;
_errorMessage = "Du bist bereits Mitglied dieses Clubs oder hast einen offenen Antrag.";
return;
}
}
catch (Exception ex)
{
_invalidToken = true;
_errorMessage = $"Fehler beim Laden der Einladung: {ex.Message}";
}
finally
{
_isLoading = false;
}
}
private async Task SubmitRequest()
{
if (_invitation is null || _currentUser is null)
return;
_isSubmitting = true;
_error = null;
try
{
var success = await UserService.RequestClubMembershipByInviteAsync(
_currentUser.ProfileId,
Token);
if (success)
{
_requestSent = true;
Snackbar.Add("Beitrittsantrag erfolgreich gesendet", Severity.Success);
}
else
{
_error = "Der Antrag konnte nicht gesendet werden.";
}
}
catch (Exception ex)
{
_error = $"Fehler: {ex.Message}";
}
finally
{
_isSubmitting = false;
}
}
}

View File

@ -62,12 +62,20 @@ namespace Koogle.Web.Controllers
var result = await _userService.RegisterUserAsync(input);
if (result.Succeeded)
{
// If invite token was provided, redirect to join page
if (!string.IsNullOrWhiteSpace(input.InviteToken))
{
return LocalRedirect($"/account/login?registered=true&invite={Uri.EscapeDataString(input.InviteToken)}");
}
return LocalRedirect("/account/login?registered=true");
}
// Build error string from IdentityResult
var errors = string.Join(",", result.Errors.Select(e => e.Code));
return LocalRedirect($"/account/register?error={errors}");
var inviteParam = !string.IsNullOrWhiteSpace(input.InviteToken)
? $"&invite={Uri.EscapeDataString(input.InviteToken)}"
: "";
return LocalRedirect($"/account/register?error={errors}{inviteParam}");
}
/// <summary>