Change organization management to use Razor Pages model binding. New accounts are created in current organization.

This commit is contained in:
Jared Goodwin 2020-02-24 23:19:58 -08:00
parent 62245979b7
commit 4a345bff2c
9 changed files with 220 additions and 198 deletions

View File

@ -73,22 +73,24 @@ namespace Remotely.Server.API
return Ok("ok");
}
[HttpPut("Name")]
[HttpDelete("DeleteUser/{userID}")]
[ServiceFilter(typeof(ApiAuthorizationFilter))]
public IActionResult Name([FromBody]string organizationName)
public async Task<IActionResult> DeleteUser(string userID)
{
if (User.Identity.IsAuthenticated &&
!DataService.GetUserByName(User.Identity.Name).IsAdministrator)
{
return Unauthorized();
}
if (organizationName.Length > 25)
if (User.Identity.IsAuthenticated &&
DataService.GetUserByName(User.Identity.Name).Id == userID)
{
return BadRequest();
return BadRequest("You can't delete yourself here. You must go to the Personal Data page to delete your own account.");
}
Request.Headers.TryGetValue("OrganizationID", out var orgID);
DataService.UpdateOrganizationName(orgID, organizationName.Trim());
await DataService.RemoveUserFromOrganization(orgID, userID);
return Ok("ok");
}
@ -131,27 +133,55 @@ namespace Remotely.Server.API
return Ok(deviceGroupID);
}
[HttpDelete("DeleteUser/{userID}")]
[HttpGet("GenerateResetUrl")]
[ServiceFilter(typeof(ApiAuthorizationFilter))]
public async Task<IActionResult> DeleteUser(string userID)
public async Task<IActionResult> GenerateResetUrl(string userEmail)
{
if (User.Identity.IsAuthenticated &&
!DataService.GetUserByName(User.Identity.Name).IsAdministrator)
{
return Unauthorized();
}
Request.Headers.TryGetValue("OrganizationID", out var orgID);
var user = await UserManager.FindByEmailAsync(userEmail);
if (user.OrganizationID != orgID)
{
return Unauthorized();
}
var code = await UserManager.GeneratePasswordResetTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ResetPassword",
pageHandler: null,
values: new { area = "Identity", code },
protocol: Request.Scheme);
return Ok(callbackUrl);
}
[HttpPut("Name")]
[ServiceFilter(typeof(ApiAuthorizationFilter))]
public IActionResult Name([FromBody]string organizationName)
{
if (User.Identity.IsAuthenticated &&
!DataService.GetUserByName(User.Identity.Name).IsAdministrator)
{
return Unauthorized();
}
if (User.Identity.IsAuthenticated &&
DataService.GetUserByName(User.Identity.Name).Id == userID)
if (organizationName.Length > 25)
{
return BadRequest("You can't delete yourself here. You must go to the Personal Data page to delete your own account.");
return BadRequest();
}
Request.Headers.TryGetValue("OrganizationID", out var orgID);
await DataService.RemoveUserFromOrganization(orgID, userID);
DataService.UpdateOrganizationName(orgID, organizationName.Trim());
return Ok("ok");
}
[HttpPost("SendInvite")]
[ServiceFilter(typeof(ApiAuthorizationFilter))]
public async Task<IActionResult> SendInvite([FromBody]Invite invite)
@ -166,54 +196,49 @@ namespace Remotely.Server.API
return BadRequest();
}
var newUserMessage = "";
Request.Headers.TryGetValue("OrganizationID", out var orgID);
if (!DataService.DoesUserExist(invite.InvitedUser))
{
var user = new RemotelyUser { UserName = invite.InvitedUser, Email = invite.InvitedUser };
{
var user = new RemotelyUser { UserName = invite.InvitedUser, Email = invite.InvitedUser, OrganizationID = orgID };
var result = await UserManager.CreateAsync(user);
if (result.Succeeded)
{
if (!DataService.SetNewUserProperties(user.UserName, orgID, invite.IsAdmin))
{
return BadRequest();
}
user = await UserManager.FindByEmailAsync(invite.InvitedUser);
await UserManager.ConfirmEmailAsync(user, await UserManager.GenerateEmailConfirmationTokenAsync(user));
var code = await UserManager.GeneratePasswordResetTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ResetPassword",
pageHandler: null,
values: new { area = "Identity", code },
protocol: Request.Scheme);
invite.ResetUrl = callbackUrl;
newUserMessage = $@"<br><br>Since you don't have an account yet, one has been created for you.
You will need to set a password first before attempting to join the organization.<br><br>
Set your password by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>. Your username/email
is <strong>{invite.InvitedUser}</strong>.";
return Ok();
}
else
{
return BadRequest("There was an issue creating the new account.");
}
}
else
{
var newInvite = DataService.AddInvite(orgID, invite);
Request.Headers.TryGetValue("OrganizationID", out var orgID);
var newInvite = DataService.AddInvite(orgID, invite);
var inviteURL = $"{Request.Scheme}://{Request.Host}/Invite?id={newInvite.ID}";
await EmailSender.SendEmailAsync(invite.InvitedUser, "Invitation to Organization in Remotely",
$@"<img src='https://remotely.lucency.co/images/Remotely_Logo.png'/>
var inviteURL = $"{Request.Scheme}://{Request.Host}/Invite?id={newInvite.ID}";
await EmailSender.SendEmailAsync(invite.InvitedUser, "Invitation to Organization in Remotely",
$@"<img src='https://remotely.lucency.co/images/Remotely_Logo.png'/>
<br><br>
Hello!
<br><br>
You've been invited to join an organization in Remotely.
{newUserMessage}
<br><br>
You can join the organization by <a href='{HtmlEncoder.Default.Encode(inviteURL)}'>clicking here</a>.");
return Ok(newInvite);
return Ok();
}
}
}
}

View File

@ -7,13 +7,7 @@
<div class="row">
<div class="col-md-6">
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<div class="alert alert-success alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
@Model.Message
</div>
}
<partial name="_StatusMessage" for="Message" />
<form method="post">
<div asp-validation-summary="All"></div>

View File

@ -12,6 +12,10 @@
@if (isAdmin)
{
<partial name="_StatusMessage" for="StatusMessage" />
<div asp-validation-summary="All" class="text-danger"></div>
<div class="row">
<div class="col-sm-8">
@* Organization ID *@
@ -73,25 +77,28 @@
<tr>
<th>User Name</th>
<th>Administrator</th>
<th>Reset Password</th>
<th>Remove</th>
</tr>
</thead>
<tbody>
@for (var i = 0; i < Model.Users.Count; i++)
{
<tr user="@Model.Users[i].ID">
<td class="middle-aligned"><label class="control-label">@Model.Users[i].UserName</label></td>
@if (currentUser.Id == Model.Users[i].ID)
{
<td>@Html.CheckBoxFor(x => x.Users[i].IsAdmin, new { user = Model.Users[i].ID, @class = "user-is-admin-checkbox", disabled = "disabled" })</td>
<td><button type="button" class="btn btn-danger delete-user-button" user="@Model.Users[i].ID" disabled>Delete</button></td>
}
else
{
<td>@Html.CheckBoxFor(x => x.Users[i].IsAdmin, new { user = Model.Users[i].ID, @class = "user-is-admin-checkbox" })</td>
<td><button type="button" class="btn btn-danger delete-user-button" user="@Model.Users[i].ID">Delete</button></td>
}
</tr>
<tr user="@Model.Users[i].ID">
<td class="middle-aligned"><label class="control-label">@Model.Users[i].UserName</label></td>
@if (currentUser.Id == Model.Users[i].ID)
{
<td>@Html.CheckBoxFor(x => x.Users[i].IsAdmin, new { user = Model.Users[i].ID, @class = "user-is-admin-checkbox", disabled = "disabled" })</td>
<td></td>
<td><button type="button" class="btn btn-danger delete-user-button" user="@Model.Users[i].ID" disabled>Delete</button></td>
}
else
{
<td>@Html.CheckBoxFor(x => x.Users[i].IsAdmin, new { user = Model.Users[i].ID, @class = "user-is-admin-checkbox" })</td>
<td><button type="button" class="btn btn-danger reset-password-button" user="@Model.Users[i].ID">Reset</button></td>
<td><button type="button" class="btn btn-danger delete-user-button" user="@Model.Users[i].ID">Delete</button></td>
}
</tr>
}
</tbody>
</table>
@ -120,15 +127,10 @@
{
<tr invite="@Model.Invites[i].ID">
<td class="middle-aligned"><label class="control-label">@Model.Invites[i].InvitedUser</label></td>
<td class="middle-aligned text-center">@Html.CheckBoxFor(x => x.Invites[i].IsAdmin, new { disabled = "disabled" })</td>
<td class="middle-aligned">@Html.CheckBoxFor(x => x.Invites[i].IsAdmin, new { disabled = "disabled" })</td>
<td class="middle-aligned">
<label class="control-label">
<a href="@Request.Scheme://@Request.Host/Invite/?id=@Model.Invites[i].ID">Join Link</a>
@if (!string.IsNullOrWhiteSpace(Model.Invites[i].ResetUrl))
{
<br />
<a href="@Model.Invites[i].ResetUrl">Password Reset</a>
}
</label>
</td>
<td><button type="button" class="btn btn-danger delete-invite-button" invite="@Model.Invites[i].ID">Delete</button></td>
@ -144,22 +146,26 @@
@* Send Invites *@
<div class="row">
<div class="col-sm-8">
<div class="form-group">
<div class="input-group">
<input id="inviteUserInput" placeholder="Username/email to invite" type="email" required class="form-control" />
<div class="input-group-append">
<span class="input-group-text pr-1">Admin?</span>
</div>
<div class="input-group-append">
<div class="input-group-text pl-1">
<input id="inviteIsAdmin" class="checkbox-inline" type="checkbox" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<form method="post" asp-page-handler="SendInvite">
<div class="form-group">
<div class="input-group">
<input asp-for="Input.UserEmail" placeholder="Username/email to invite" type="email" required class="form-control" />
<div class="input-group-append">
<span class="input-group-text pr-1">Admin?</span>
</div>
<div class="input-group-append">
<div class="input-group-text pl-1">
<input asp-for="Input.IsAdmin" class="checkbox-inline" type="checkbox" />
</div>
</div>
<div class="input-group-append">
<button type="submit" class="btn btn-secondary">Invite</button>
</div>
</div>
<div class="input-group-append">
<button id="sendInviteButton" type="button" class="btn btn-secondary">Invite</button>
</div>
</div>
</div>
</form>
</div>
</div>

View File

@ -8,14 +8,21 @@ using System.ComponentModel.DataAnnotations;
using System.Linq;
using Remotely.Shared.ViewModels.Organization;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Identity.UI.Services;
using System;
namespace Remotely.Server.Areas.Identity.Pages.Account.Manage
{
public class OrganizationModel : PageModel
{
public OrganizationModel(DataService dataService)
public OrganizationModel(DataService dataService, UserManager<RemotelyUser> userManager, IEmailSender emailSender)
{
DataService = dataService;
UserManager = userManager;
EmailSender = emailSender;
}
public List<SelectListItem> DeviceGroups { get; } = new List<SelectListItem>();
@ -29,8 +36,31 @@ namespace Remotely.Server.Areas.Identity.Pages.Account.Manage
[Display(Name = "Users")]
public List<OrganizationUser> Users { get; set; }
public class InputModel
{
public bool IsAdmin { get; set; }
[EmailAddress]
public string UserEmail { get; set; }
}
[BindProperty]
public InputModel Input { get; set; } = new InputModel();
[TempData]
public string StatusMessage { get; set; }
private DataService DataService { get; }
private UserManager<RemotelyUser> UserManager { get; }
private IEmailSender EmailSender { get; }
public void OnGet()
{
PopulateViewModel();
}
private void PopulateViewModel()
{
OrganizationName = DataService.GetOrganizationName(User.Identity.Name);
@ -50,10 +80,73 @@ namespace Remotely.Server.Areas.Identity.Pages.Account.Manage
ID = x.ID,
InvitedUser = x.InvitedUser,
IsAdmin = x.IsAdmin,
DateSent = x.DateSent,
ResetUrl = x.ResetUrl
DateSent = x.DateSent
}).ToList();
}
public async Task<IActionResult> OnPostSendInviteAsync()
{
var currentUser = await UserManager.FindByEmailAsync(User.Identity.Name);
if (!currentUser.IsAdministrator)
{
return RedirectToPage("Index");
}
if (ModelState.IsValid)
{
if (!DataService.DoesUserExist(Input.UserEmail))
{
var user = new RemotelyUser { UserName = Input.UserEmail, Email = Input.UserEmail };
var result = await UserManager.CreateAsync(user);
if (result.Succeeded)
{
if (!DataService.SetNewUserProperties(user.UserName, currentUser.OrganizationID, Input.IsAdmin))
{
ModelState.AddModelError("OrgID", "Failed to set organization ID.");
return Page();
}
user = await UserManager.FindByEmailAsync(Input.UserEmail);
await UserManager.ConfirmEmailAsync(user, await UserManager.GenerateEmailConfirmationTokenAsync(user));
StatusMessage = "User account created.";
return RedirectToPage();
}
else
{
ModelState.AddModelError("CreateUser", "Failed to create user account.");
return Page();
}
}
else
{
var invite = new Invite()
{
InvitedUser = Input.UserEmail,
IsAdmin = Input.IsAdmin
};
var newInvite = DataService.AddInvite(currentUser.OrganizationID, invite);
var inviteURL = $"{Request.Scheme}://{Request.Host}/Invite?id={newInvite.ID}";
await EmailSender.SendEmailAsync(invite.InvitedUser, "Invitation to Organization in Remotely",
$@"<img src='https://remotely.lucency.co/images/Remotely_Logo.png'/>
<br><br>
Hello!
<br><br>
You've been invited to join an organization in Remotely.
<br><br>
You can join the organization by <a href='{HtmlEncoder.Default.Encode(inviteURL)}'>clicking here</a>.");
StatusMessage = "Invitation sent.";
return RedirectToPage();
}
}
return Page();
}
}
}

View File

@ -82,8 +82,7 @@ namespace Remotely.Server.Services
InvitedUser = invite.InvitedUser,
IsAdmin = invite.IsAdmin,
Organization = organization,
OrganizationID = organization.ID,
ResetUrl = invite.ResetUrl
OrganizationID = organization.ID
};
organization.InviteLinks.Add(newInvite);
RemotelyContext.SaveChanges();
@ -106,6 +105,18 @@ namespace Remotely.Server.Services
RemotelyContext.SaveChanges();
}
public bool SetNewUserProperties(string targetName, string organizationID, bool isAdmin)
{
var targetUser = GetUserByName(targetName);
targetUser.OrganizationID = organizationID;
targetUser.IsAdministrator = isAdmin;
RemotelyContext.SaveChanges();
return true;
}
public bool AddOrUpdateDevice(Device device, out Device updatedDevice)
{
device.LastOnline = DateTime.Now;

View File

@ -1,15 +1,14 @@
import { ShowModal, ValidateInput, PopupMessage } from "../UI.js";
import { ShowModal } from "../UI.js";
document.getElementById("usersHelpButton").addEventListener("click", (ev) => {
ShowModal("Users", `All users for the organization are managed here.<br><br>
Administrators will have access to this management screen as well as all computers.`);
});
document.getElementById("invitesHelpButton").addEventListener("click", (ev) => {
ShowModal("Invitations", `All pending invitations will be shown here and can be revoked by deleting them.<br><br>
If a user does not exist, sending an invite will create their account and send them a password reset email too.
The password reset must be completed before accepting the invitation.
If a user does not exist, sending an invite will create their account and add them to the current organization.
A password reset URL can be generated from the user table.
<br><br>
The Admin checkbox determines if the new user will have administrator privileges in this organization
after they accept the invitation.`);
The Admin checkbox determines if the new user will have administrator privileges in this organization.`);
});
document.getElementById("deviceGroupHelpButton").addEventListener("click", (ev) => {
ShowModal("Device Groups", `Device groups can be used to organize and filter computers on the grid.`);
@ -148,57 +147,6 @@ document.querySelectorAll(".delete-user-button").forEach((removeButton) => {
}
});
});
document.getElementById("sendInviteButton").addEventListener("click", (ev) => {
var inviteUserInput = document.querySelector("#inviteUserInput");
if (!ValidateInput(inviteUserInput)) {
return;
}
var invitedUser = inviteUserInput.value;
inviteUserInput.value = "";
var isAdmin = document.getElementById("inviteIsAdmin").checked;
var xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status == 200) {
var newInvite = JSON.parse(xhr.responseText);
var tbody = document.querySelector("#invitesTable tbody");
var newRow = document.createElement("tr");
newRow.setAttribute("invite", newInvite.ID);
var innerHtml = `<td class="middle-aligned"><label class="control-label">${newInvite.InvitedUser}</label></td>
<td class="middle-aligned text-center"><input type="checkbox" disabled ${newInvite.IsAdmin ? "checked" : ""}/></td>
<td class="middle-aligned">
<label class="control-label">
<a href="${location.origin}/Invite/?id=${newInvite.ID}">Join Link</a>`;
if (newInvite.ResetUrl) {
innerHtml += `<br /> <a href="${newInvite.ResetUrl}">Reset Password</a>`;
}
innerHtml += ` </label> </td>
<td><button type="button" class="btn btn-danger delete-invite-button" invite="${newInvite.ID}">Delete</button></td>`;
newRow.innerHTML = innerHtml;
tbody.appendChild(newRow);
newRow.querySelector(".delete-invite-button").addEventListener("click", (ev) => {
deleteInvite(ev);
});
}
else if (xhr.status == 400) {
ShowModal("Invalid Request", xhr.responseText);
}
else {
showError(xhr);
}
};
xhr.onerror = () => {
showError(xhr);
};
xhr.open("post", location.origin + `/api/OrganizationManagement/SendInvite/`);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ InvitedUser: invitedUser, IsAdmin: isAdmin }));
PopupMessage("Sending invite...");
});
document.getElementById("inviteUserInput").addEventListener("keypress", (e) => {
if (e.key.toLowerCase() == "enter") {
document.getElementById("sendInviteButton").click();
}
});
document.querySelectorAll(".delete-invite-button").forEach((deleteButton) => {
deleteButton.addEventListener("click", (ev) => {
deleteInvite(ev);

File diff suppressed because one or more lines are too long

View File

@ -7,11 +7,10 @@ document.getElementById("usersHelpButton").addEventListener("click", (ev) => {
});
document.getElementById("invitesHelpButton").addEventListener("click", (ev) => {
ShowModal("Invitations", `All pending invitations will be shown here and can be revoked by deleting them.<br><br>
If a user does not exist, sending an invite will create their account and send them a password reset email too.
The password reset must be completed before accepting the invitation.
If a user does not exist, sending an invite will create their account and add them to the current organization.
A password reset URL can be generated from the user table.
<br><br>
The Admin checkbox determines if the new user will have administrator privileges in this organization
after they accept the invitation.`);
The Admin checkbox determines if the new user will have administrator privileges in this organization.`);
});
document.getElementById("deviceGroupHelpButton").addEventListener("click", (ev) => {
@ -160,59 +159,6 @@ document.querySelectorAll(".delete-user-button").forEach((removeButton: HTMLButt
})
});
document.getElementById("sendInviteButton").addEventListener("click", (ev) => {
var inviteUserInput = document.querySelector("#inviteUserInput") as HTMLInputElement;
if (!ValidateInput(inviteUserInput)) {
return;
}
var invitedUser = inviteUserInput.value;
inviteUserInput.value = "";
var isAdmin = (document.getElementById("inviteIsAdmin") as HTMLInputElement).checked;
var xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status == 200) {
var newInvite = JSON.parse(xhr.responseText);
var tbody = document.querySelector("#invitesTable tbody");
var newRow = document.createElement("tr");
newRow.setAttribute("invite", newInvite.ID);
var innerHtml = `<td class="middle-aligned"><label class="control-label">${newInvite.InvitedUser}</label></td>
<td class="middle-aligned text-center"><input type="checkbox" disabled ${newInvite.IsAdmin ? "checked" : ""}/></td>
<td class="middle-aligned">
<label class="control-label">
<a href="${location.origin}/Invite/?id=${newInvite.ID}">Join Link</a>`;
if (newInvite.ResetUrl) {
innerHtml += `<br /> <a href="${newInvite.ResetUrl}">Reset Password</a>`;
}
innerHtml += ` </label> </td>
<td><button type="button" class="btn btn-danger delete-invite-button" invite="${newInvite.ID}">Delete</button></td>`;
newRow.innerHTML = innerHtml;
tbody.appendChild(newRow);
newRow.querySelector(".delete-invite-button").addEventListener("click", (ev:MouseEvent) => {
deleteInvite(ev);
})
}
else if (xhr.status == 400) {
ShowModal("Invalid Request", xhr.responseText);
}
else {
showError(xhr);
}
}
xhr.onerror = () => {
showError(xhr);
}
xhr.open("post", location.origin + `/api/OrganizationManagement/SendInvite/`);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ InvitedUser: invitedUser, IsAdmin: isAdmin }));
PopupMessage("Sending invite...");
});
document.getElementById("inviteUserInput").addEventListener("keypress", (e) => {
if (e.key.toLowerCase() == "enter") {
document.getElementById("sendInviteButton").click();
}
})
document.querySelectorAll(".delete-invite-button").forEach((deleteButton: HTMLButtonElement) => {
deleteButton.addEventListener("click", (ev) => {
deleteInvite(ev);

View File

@ -10,6 +10,5 @@ namespace Remotely.Shared.ViewModels.Organization
public bool IsAdmin { get; set; }
public DateTime DateSent { get; set; }
public string InvitedUser { get; set; }
public string ResetUrl { get; set; }
}
}