Skip to content

Commit

Permalink
Finish invite table and controller.
Browse files Browse the repository at this point in the history
  • Loading branch information
bitbound committed Nov 17, 2024
1 parent 349d24d commit cb65b42
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 30 deletions.
77 changes: 70 additions & 7 deletions ControlR.Web.Client/Components/Pages/Invite.razor
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
@inject IInviteStore InviteStore
@inject IControlrApi ControlrApi
@inject ISnackbar Snackbar
@inject IClipboardManager ClipboardManager
@inject IDialogService DialogService

<div>

Expand Down Expand Up @@ -83,7 +85,7 @@
Filterable="true"
Title="Created At">
<CellTemplate>
@(context.Item.CreatedAt)
@(context.Item.CreatedAt)
</CellTemplate>
</TemplateColumn>

Expand All @@ -92,7 +94,30 @@
Filterable="true"
Title="Invite URL">
<CellTemplate>
@(context.Item.InviteUrl)
<MudTextField T="Uri"
Label="Copy to share URL"
ReadOnly="true"
Variant="Variant.Filled"
InputType="InputType.Password"
AdornmentIcon="@(Icons.Material.Filled.ContentCopy)"
OnAdornmentClick="@(() => CopyInviteUrl(context.Item.InviteUrl))"
AdornmentColor="Color.Secondary"
Adornment="Adornment.End"
Value="context.Item.InviteUrl" />
</CellTemplate>
</TemplateColumn>
<TemplateColumn T="TenantInviteResponseDto"
Sortable="false"
Filterable="false"
ShowColumnOptions="false"
Title="Delete Invite">
<CellTemplate>
<MudTooltip Text="Delete Invitation">
<MudIconButton Icon="@(Icons.Material.Filled.DeleteForever)"
ButtonType="ButtonType.Button"
Color="Color.Error"
OnClick="@(() => DeleteInvite(context.Item))" />
</MudTooltip>
</CellTemplate>
</TemplateColumn>
</Columns>
Expand All @@ -116,10 +141,10 @@
private readonly Dictionary<string, SortDefinition<TenantInviteResponseDto>> _sortDefinitions = new()
{
["CreatedAt"] = new SortDefinition<TenantInviteResponseDto>(
SortBy: nameof(TenantInviteResponseDto.CreatedAt),
Descending: false,
Index: 0,
SortFunc: x => x.CreatedAt)
SortBy: nameof(TenantInviteResponseDto.CreatedAt),
Descending: false,
Index: 0,
SortFunc: x => x.CreatedAt)
};

protected override async Task OnInitializedAsync()
Expand All @@ -132,6 +157,44 @@
_loading = false;
}

private async Task CopyInviteUrl(Uri inviteUrl)
{
try
{
await ClipboardManager.SetText(inviteUrl.ToString());
Snackbar.Add("Copied to clipboard", Severity.Success);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error while copying invite URL to clipboard.");
Snackbar.Add("Error while copying invite URL to clipboard", Severity.Error);
}
}

private async Task DeleteInvite(TenantInviteResponseDto dto)
{
var result = await DialogService.ShowMessageBox(
"Confirm Delete",
"Are you sure you want to delete this invitaiton and user?",
yesText: "Yes",
noText: "No");

if (!result.HasValue || !result.Value)
{
return;
}

var deleteResult = await ControlrApi.DeleteTenantInvite(dto.Id);
if (!deleteResult.IsSuccess)
{
Snackbar.Add(deleteResult.Reason, Severity.Error);
return;
}

await InviteStore.Remove(dto.Id);
await InvokeAsync(StateHasChanged);
Snackbar.Add("Invite deleted", Severity.Success);
}

private async Task HandleInviteStoreChanged()
{
Expand All @@ -146,7 +209,6 @@
}
}


private async Task InvokeSubmit()
{
if (_editContext?.Validate() == false)
Expand All @@ -168,6 +230,7 @@
_inputModel.Email = string.Empty;
await InviteStore.Refresh();
await InvokeAsync(StateHasChanged);
Snackbar.Add("Invite created", Severity.Success);
}

private async Task HandleRefreshClicked()
Expand Down
37 changes: 36 additions & 1 deletion ControlR.Web.Server/Api/InvitesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,20 @@ public async Task<ActionResult<TenantInviteResponseDto>> Create(
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons

var randomPassword = RandomGenerator.GenerateString(64);
var createResult = await userCreator.CreateUser(dto.InviteeEmail, randomPassword, returnUrl: null);
var createResult = await userCreator.CreateUser(
dto.InviteeEmail,
password: randomPassword,
tenantId: tenantId);

if (!createResult.Succeeded)
{
var firstError = createResult.IdentityResult.Errors.FirstOrDefault();

if (firstError is { Code: nameof(IdentityErrorDescriber.DuplicateUserName) } idError)
{
return Conflict("User already exists.");
}

return Problem("Failed to create user.");
}

Expand All @@ -63,10 +74,34 @@ public async Task<ActionResult<TenantInviteResponseDto>> Create(
TenantId = tenantId,
};
await appDb.TenantInvites.AddAsync(invite);
await appDb.SaveChangesAsync();
var newUser = createResult.User;
var origin = Request.ToOrigin();
var inviteUrl = new Uri(origin, $"{ClientRoutes.InviteConfirmationBase}/{invite.ActivationCode}");
var retDto = new TenantInviteResponseDto(invite.Id, invite.CreatedAt, normalizedEmail, inviteUrl);
return Ok(retDto);
}

[HttpDelete("{inviteId:guid}")]
public async Task<IActionResult> Delete(
[FromRoute] Guid inviteId,
[FromServices] AppDb appDb,
[FromServices]UserManager<AppUser> userManager)
{
var invite = await appDb.TenantInvites.FindAsync(inviteId);
if (invite is null)
{
return NotFound();
}

var user = await userManager.FindByEmailAsync(invite.InviteeEmail);
appDb.TenantInvites.Remove(invite);
await appDb.SaveChangesAsync();

if (user is not null)
{
await userManager.DeleteAsync(user);
}
return NoContent();
}
}
86 changes: 66 additions & 20 deletions ControlR.Web.Server/Services/UserCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,59 +9,96 @@ namespace ControlR.Web.Server.Services;

public interface IUserCreator
{
Task<CreateUserResult> CreateUser(string emailAddress, string password, string? returnUrl);
Task<CreateUserResult> CreateUser(
string emailAddress,
string password,
string? returnUrl);

Task<CreateUserResult> CreateUser(
string emailAddress,
string password,
Guid tenantId);

Task<CreateUserResult> CreateUser(
string emailAddress,
string emailAddress,
ExternalLoginInfo externalLoginInfo,
string? returnUrl);
}

public class UserCreator(
AppDb appDb,
UserManager<AppUser> userManager,
NavigationManager navigationManager,
IUserStore<AppUser> userStore,
IEmailSender<AppUser> emailSender,
ILogger<UserCreator> logger) : IUserCreator
{
private readonly AppDb _appDb = appDb;
private readonly IEmailSender<AppUser> _emailSender = emailSender;
private readonly ILogger<UserCreator> _logger = logger;
private readonly NavigationManager _navigationManager = navigationManager;
private readonly UserManager<AppUser> _userManager = userManager;
private readonly IUserStore<AppUser> _userStore = userStore;

public async Task<CreateUserResult> CreateUser(string emailAddress, string password, string? returnUrl)
public async Task<CreateUserResult> CreateUser(
string emailAddress,
string password,
string? returnUrl)
{
return await CreateUserImpl(emailAddress, returnUrl, password);
return await CreateUserImpl(
emailAddress,
returnUrl: returnUrl,
password: password);
}

public async Task<CreateUserResult> CreateUser(
string emailAddress,
string emailAddress,
ExternalLoginInfo externalLoginInfo,
string? returnUrl)
{
return await CreateUserImpl(emailAddress, returnUrl, password: null, externalLoginInfo);
return await CreateUserImpl(
emailAddress,
returnUrl: returnUrl,
externalLoginInfo: externalLoginInfo);
}

public async Task<CreateUserResult> CreateUser(string emailAddress, string password, Guid tenantId)
{
return await CreateUserImpl(
emailAddress,
password: password,
tenantId: tenantId);
}

private async Task<CreateUserResult> CreateUserImpl(
string emailAddress,
string? returnUrl,
string? password = null,
ExternalLoginInfo? externalLoginInfo = null)
ExternalLoginInfo? externalLoginInfo = null,
string? returnUrl = null,
Guid? tenantId = null)
{
try
{
var tenant = new Tenant();
var user = new AppUser

var user = new AppUser();

if (tenantId is not null)
{
Tenant = tenant
};
user.TenantId = tenantId.Value;
}
else
{
var tenant = new Tenant();
user.Tenant = tenant;
}

await _userStore.SetUserNameAsync(user, emailAddress, CancellationToken.None);

if (_userStore is not IUserEmailStore<AppUser> userEmailStore)
{
throw new InvalidOperationException("The user store does not implement the IUserEmailStore<AppUser>.");
}

await userEmailStore.SetEmailAsync(user, emailAddress, CancellationToken.None);

var identityResult = string.IsNullOrWhiteSpace(password)
Expand All @@ -85,8 +122,8 @@ private async Task<CreateUserResult> CreateUserImpl(

await _userManager.AddClaimAsync(user, new Claim(UserClaimTypes.UserId, $"{user.Id}"));
_logger.LogInformation("Added user's ID claim.");
await _userManager.AddClaimAsync(user, new Claim(UserClaimTypes.TenantId, $"{tenant.Id}"));

await _userManager.AddClaimAsync(user, new Claim(UserClaimTypes.TenantId, $"{user.TenantId}"));
_logger.LogInformation("Added user's tenant ID claim.");

await _userManager.AddToRoleAsync(user, RoleNames.TenantAdministrator);
Expand Down Expand Up @@ -115,12 +152,21 @@ private async Task<CreateUserResult> CreateUserImpl(

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, ["returnUrl"] = returnUrl });

await _emailSender.SendConfirmationLinkAsync(user, emailAddress, HtmlEncoder.Default.Encode(callbackUrl));
if (tenantId is not null)
{
await _userManager.ConfirmEmailAsync(user, code);
}
else
{
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = _navigationManager.GetUriWithQueryParameters(
_navigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code, ["returnUrl"] = returnUrl });

await _emailSender.SendConfirmationLinkAsync(user, emailAddress, HtmlEncoder.Default.Encode(callbackUrl));
}

return new CreateUserResult(true, identityResult, user);
}
catch (Exception ex)
Expand Down
Loading

0 comments on commit cb65b42

Please sign in to comment.