summaryrefslogtreecommitdiffstats
path: root/server/src/Endpoints/Internal
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
committerivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
commita640703f2da8815dc26ad1600a6f206be1624379 (patch)
treedbda195fb5783d16487e557e06471cf848b75427 /server/src/Endpoints/Internal
downloadgreatoffice-a640703f2da8815dc26ad1600a6f206be1624379.tar.xz
greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.zip
feat: Initial after clean slate
Diffstat (limited to 'server/src/Endpoints/Internal')
-rw-r--r--server/src/Endpoints/Internal/Account/CreateAccountPayload.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/CreateAccountRoute.cs44
-rw-r--r--server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs34
-rw-r--r--server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs49
-rw-r--r--server/src/Endpoints/Internal/Account/GetArchiveRoute.cs62
-rw-r--r--server/src/Endpoints/Internal/Account/GetRoute.cs30
-rw-r--r--server/src/Endpoints/Internal/Account/LoginPayload.cs22
-rw-r--r--server/src/Endpoints/Internal/Account/LoginRoute.cs37
-rw-r--r--server/src/Endpoints/Internal/Account/LogoutRoute.cs22
-rw-r--r--server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs51
-rw-r--r--server/src/Endpoints/Internal/BaseRoute.cs16
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs59
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs14
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs34
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs29
-rw-r--r--server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs21
-rw-r--r--server/src/Endpoints/Internal/Root/LogRoute.cs16
-rw-r--r--server/src/Endpoints/Internal/RouteBaseAsync.cs73
-rw-r--r--server/src/Endpoints/Internal/RouteBaseSync.cs53
21 files changed, 717 insertions, 0 deletions
diff --git a/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs b/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs
new file mode 100644
index 0000000..dc73e68
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs
@@ -0,0 +1,17 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <summary>
+/// Payload for creating new user accounts.
+/// </summary>
+public class CreateAccountPayload
+{
+ /// <summary>
+ /// Username for the new account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password for the new account.
+ /// </summary>
+ public string Password { get; set; }
+}
diff --git a/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs b/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs
new file mode 100644
index 0000000..954fbf5
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs
@@ -0,0 +1,44 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <inheritdoc />
+public class CreateAccountRoute : RouteBaseAsync.WithRequest<CreateAccountPayload>.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public CreateAccountRoute(UserService userService, AppDbContext context) {
+ _userService = userService;
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a new user account.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/account/create")]
+ public override async Task<ActionResult> HandleAsync(CreateAccountPayload request, CancellationToken cancellationToken = default) {
+ if (request.Username.IsValidEmailAddress() == false) {
+ return BadRequest(new ErrorResult("Invalid form", request.Username + " does not look like a valid email"));
+ }
+
+ if (request.Password.Length < 6) {
+ return BadRequest(new ErrorResult("Invalid form", "The password requires 6 or more characters."));
+ }
+
+ var username = request.Username.Trim();
+ if (_context.Users.Any(c => c.Username == username)) {
+ return BadRequest(new ErrorResult("Username is not available", "There is already a user registered with email: " + username));
+ }
+
+ var user = new User(username);
+ user.HashAndSetPassword(request.Password);
+ _context.Users.Add(user);
+ await _context.SaveChangesAsync(cancellationToken);
+ await _userService.LogInUser(HttpContext, user);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs b/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs
new file mode 100644
index 0000000..0cd1aa5
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs
@@ -0,0 +1,17 @@
+using AspNet.Security.OAuth.GitHub;
+
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class CreateGithubSessionRoute : RouteBaseSync.WithRequest<string>.WithActionResult
+{
+ public CreateGithubSessionRoute(IConfiguration configuration) { }
+
+ [AllowAnonymous]
+ [HttpGet("~/_/account/create-github-session")]
+ public override ActionResult Handle(string next) {
+ return Challenge(new AuthenticationProperties {
+ RedirectUri = next
+ },
+ GitHubAuthenticationDefaults.AuthenticationScheme);
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs b/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs
new file mode 100644
index 0000000..13fbdf4
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs
@@ -0,0 +1,34 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <inheritdoc />
+public class CreateInitialAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public CreateInitialAccountRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Create an initial user account.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/account/create-initial")]
+ public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) {
+ if (_context.Users.Any()) {
+ return NotFound();
+ }
+
+ var user = new User("admin@ivarlovlie.no");
+ user.HashAndSetPassword("ivar123");
+ _context.Users.Add(user);
+ await _context.SaveChangesAsync(cancellationToken);
+ await _userService.LogInUser(HttpContext, user);
+ return Redirect("/");
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs b/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs
new file mode 100644
index 0000000..2149e15
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs
@@ -0,0 +1,49 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class DeleteAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public DeleteAccountRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Delete the logged on user's account.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpDelete("~/_/account/delete")]
+ public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user == default) {
+ await _userService.LogOutUser(HttpContext);
+ return Unauthorized();
+ }
+
+ if (user.Username == "demo@demo.demo") {
+ await _userService.LogOutUser(HttpContext);
+ return Ok();
+ }
+
+ var githubMappings = _context.TimeCategories.Where(c => c.UserId == user.Id);
+ var passwordResets = _context.ForgotPasswordRequests.Where(c => c.UserId == user.Id);
+ var entries = _context.TimeEntries.Where(c => c.UserId == user.Id);
+ var labels = _context.TimeLabels.Where(c => c.UserId == user.Id);
+ var categories = _context.TimeCategories.Where(c => c.UserId == user.Id);
+
+ _context.TimeCategories.RemoveRange(githubMappings);
+ _context.ForgotPasswordRequests.RemoveRange(passwordResets);
+ _context.TimeEntries.RemoveRange(entries);
+ _context.TimeLabels.RemoveRange(labels);
+ _context.TimeCategories.RemoveRange(categories);
+ _context.Users.Remove(user);
+
+ await _context.SaveChangesAsync(cancellationToken);
+ await _userService.LogOutUser(HttpContext);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs b/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs
new file mode 100644
index 0000000..44f5249
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs
@@ -0,0 +1,62 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class GetAccountArchiveRoute : RouteBaseAsync.WithoutRequest.WithActionResult<UserArchiveDto>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetAccountArchiveRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a data archive with the currently logged on user's data.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpGet("~/_/account/archive")]
+ public override async Task<ActionResult<UserArchiveDto>> HandleAsync(CancellationToken cancellationToken = default) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user == default) {
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+
+ var entries = _context.TimeEntries
+ .AsNoTracking()
+ .Include(c => c.Labels)
+ .Include(c => c.Category)
+ .Where(c => c.User.Id == user.Id)
+ .ToList();
+
+ var jsonOptions = new JsonSerializerOptions {
+ WriteIndented = true
+ };
+
+ var dto = new UserArchiveDto(user);
+ dto.Entries.AddRange(entries.Select(entry => new UserArchiveDto.EntryDto {
+ CreatedAt = entry.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
+ StartDateTime = entry.Start,
+ StopDateTime = entry.Stop,
+ Description = entry.Description,
+ Labels = entry.Labels
+ .Select(c => new UserArchiveDto.LabelDto {
+ Name = c.Name,
+ Color = c.Color
+ })
+ .ToList(),
+ Category = new UserArchiveDto.CategoryDto {
+ Name = entry.Category.Name,
+ Color = entry.Category.Color
+ },
+ }));
+
+ dto.CountEntries();
+
+ var entriesSerialized = JsonSerializer.SerializeToUtf8Bytes(dto, jsonOptions);
+
+ return File(entriesSerialized,
+ "application/json",
+ user.Username + "-time-tracker-archive-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + ".json");
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/GetRoute.cs b/server/src/Endpoints/Internal/Account/GetRoute.cs
new file mode 100644
index 0000000..34a3c97
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/GetRoute.cs
@@ -0,0 +1,30 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class GetAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult<LoggedInUserModel>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetAccountRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get the logged on user's session data.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpGet("~/_/account")]
+ public override async Task<ActionResult<LoggedInUserModel>> HandleAsync(CancellationToken cancellationToken = default) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user != default) {
+ return Ok(new LoggedInUserModel {
+ Id = LoggedInUser.Id,
+ Username = LoggedInUser.Username
+ });
+ }
+
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/LoginPayload.cs b/server/src/Endpoints/Internal/Account/LoginPayload.cs
new file mode 100644
index 0000000..807662c
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/LoginPayload.cs
@@ -0,0 +1,22 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <summary>
+/// Payload for logging in a user.
+/// </summary>
+public class LoginPayload
+{
+ /// <summary>
+ /// Username of the user's account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password of the user's account.
+ /// </summary>
+ public string Password { get; set; }
+
+ /// <summary>
+ /// Specify that the created session should be long lived and continually refreshed.
+ /// </summary>
+ public bool Persist { get; set; }
+}
diff --git a/server/src/Endpoints/Internal/Account/LoginRoute.cs b/server/src/Endpoints/Internal/Account/LoginRoute.cs
new file mode 100644
index 0000000..5b41c61
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/LoginRoute.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class LoginRoute : RouteBaseAsync
+ .WithRequest<LoginPayload>
+ .WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public LoginRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Login a user.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/account/login")]
+ public override async Task<ActionResult> HandleAsync(LoginPayload request, CancellationToken cancellationToken = default) {
+ if (!ModelState.IsValid) {
+ return BadRequest(ModelState);
+ }
+
+ var user = _context.Users.SingleOrDefault(u => u.Username == request.Username);
+ if (user == default || !user.VerifyPassword(request.Password)) {
+ return BadRequest(new ErrorResult("Invalid username or password"));
+ }
+
+ await _userService.LogInUser(HttpContext, user, request.Persist);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/LogoutRoute.cs b/server/src/Endpoints/Internal/Account/LogoutRoute.cs
new file mode 100644
index 0000000..4a06f4a
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/LogoutRoute.cs
@@ -0,0 +1,22 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class LogoutRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly UserService _userService;
+
+ public LogoutRoute(UserService userService) {
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Logout a user.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/account/logout")]
+ public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) {
+ await _userService.LogOutUser(HttpContext);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs b/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs
new file mode 100644
index 0000000..88a3237
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs
@@ -0,0 +1,17 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <summary>
+/// Payload for updating an account.
+/// </summary>
+public class UpdatePayload
+{
+ /// <summary>
+ /// Username to set on the logged on user's account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password to set on the logged on user's account.
+ /// </summary>
+ public string Password { get; set; }
+}
diff --git a/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs b/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs
new file mode 100644
index 0000000..a997dcb
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs
@@ -0,0 +1,51 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class UpdateAccountRoute : RouteBaseAsync.WithRequest<UpdatePayload>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public UpdateAccountRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update the logged on user's data.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpPost("~/_/account/update")]
+ public override async Task<ActionResult> HandleAsync(UpdatePayload request, CancellationToken cancellationToken = default) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user == default) {
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+
+ if (request.Password.IsNullOrWhiteSpace() && request.Username.IsNullOrWhiteSpace()) {
+ return BadRequest(new ErrorResult("Invalid request", "No data was submitted"));
+ }
+
+ if (request.Password.HasValue() && request.Password.Length < 6) {
+ return BadRequest(new ErrorResult("Invalid request",
+ "The new password must contain at least 6 characters"));
+ }
+
+ if (request.Password.HasValue()) {
+ user.HashAndSetPassword(request.Password);
+ }
+
+ if (request.Username.HasValue() && !request.Username.IsValidEmailAddress()) {
+ return BadRequest(new ErrorResult("Invalid request",
+ "The new username does not look like a valid email address"));
+ }
+
+ if (request.Username.HasValue()) {
+ user.Username = request.Username.Trim();
+ }
+
+ await _context.SaveChangesAsync(cancellationToken);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/BaseRoute.cs b/server/src/Endpoints/Internal/BaseRoute.cs
new file mode 100644
index 0000000..3e2c6af
--- /dev/null
+++ b/server/src/Endpoints/Internal/BaseRoute.cs
@@ -0,0 +1,16 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal;
+
+[Authorize]
+[ApiController]
+[ApiExplorerSettings(IgnoreApi = true)]
+[ApiVersionNeutral]
+public class BaseRoute : ControllerBase
+{
+ /// <summary>
+ /// User data for the currently logged on user.
+ /// </summary>
+ protected LoggedInUserModel LoggedInUser => new() {
+ Username = User.FindFirstValue(AppClaims.NAME),
+ Id = User.FindFirstValue(AppClaims.USER_ID).AsGuid(),
+ };
+}
diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs
new file mode 100644
index 0000000..3e086f6
--- /dev/null
+++ b/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs
@@ -0,0 +1,59 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+/// <inheritdoc />
+public class CreateResetRequestRoute : RouteBaseAsync.WithRequest<string>.WithActionResult
+{
+ private readonly ILogger<CreateResetRequestRoute> _logger;
+ private readonly ForgotPasswordService _forgotPasswordService;
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public CreateResetRequestRoute(ILogger<CreateResetRequestRoute> logger, ForgotPasswordService forgotPasswordService, AppDbContext context) {
+ _logger = logger;
+ _forgotPasswordService = forgotPasswordService;
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a new password reset request.
+ /// </summary>
+ /// <param name="username"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/forgot-password-requests/create")]
+ public override async Task<ActionResult> HandleAsync(string username, CancellationToken cancellationToken = default) {
+ if (!username.IsValidEmailAddress()) {
+ _logger.LogInformation("Username is invalid, not doing request for password change");
+ return BadRequest(new ErrorResult("Invalid email address", username + " looks like an invalid email address"));
+ }
+
+ Request.Headers.TryGetValue(AppHeaders.BROWSER_TIME_ZONE, out var timeZoneHeader);
+ var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneHeader.ToString().HasValue() ? timeZoneHeader.ToString() : "UTC");
+ var offset = tz.BaseUtcOffset.Hours;
+
+ // this is fine as long as the client is not connecting from Australia: Lord Howe Island
+ // according to https://en.wikipedia.org/wiki/Daylight_saving_time_by_country
+ if (tz.IsDaylightSavingTime(DateTime.UtcNow)) {
+ offset++;
+ }
+
+ _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offset + " hours");
+ var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
+ _logger.LogInformation("Creating forgot password request with date time: " + requestDateTime.ToString("u"));
+
+ try {
+ var user = _context.Users.SingleOrDefault(c => c.Username.Equals(username));
+ if (user != default) {
+ await _forgotPasswordService.AddRequestAsync(user, tz, cancellationToken);
+ return Ok();
+ }
+
+ _logger.LogInformation("User was not found, not doing request for password change");
+ return Ok();
+ } catch (Exception e) {
+ _logger.LogError(e, "ForgotAction failed badly");
+ return Ok();
+ }
+ }
+}
diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs
new file mode 100644
index 0000000..f0fb59f
--- /dev/null
+++ b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs
@@ -0,0 +1,14 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+public class FulfillResetRequestPayload
+{
+ /// <summary>
+ /// Id of the password reset request to fulfill
+ /// </summary>
+ public Guid Id { get; set; }
+
+ /// <summary>
+ /// New password to set on the relevant account
+ /// </summary>
+ public string NewPassword { get; set; }
+}
diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs
new file mode 100644
index 0000000..e33a4fb
--- /dev/null
+++ b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs
@@ -0,0 +1,34 @@
+
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+/// <inheritdoc />
+public class FulfillResetRequestRoute : RouteBaseAsync.WithRequest<FulfillResetRequestPayload>.WithActionResult
+{
+ private readonly ForgotPasswordService _forgotPasswordService;
+
+ /// <inheritdoc />
+ public FulfillResetRequestRoute(ForgotPasswordService forgotPasswordService) {
+ _forgotPasswordService = forgotPasswordService;
+ }
+
+ /// <summary>
+ /// Fulfill a password reset request.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/forgot-password-requests/fulfill")]
+ public override async Task<ActionResult> HandleAsync(FulfillResetRequestPayload request, CancellationToken cancellationToken = default) {
+ try {
+ var fulfilled = await _forgotPasswordService.FullFillRequestAsync(request.Id, request.NewPassword, cancellationToken);
+ return Ok(fulfilled);
+ } catch (Exception e) {
+ if (e is ForgotPasswordRequestNotFoundException or UserNotFoundException) {
+ return NotFound();
+ }
+
+ throw;
+ }
+ }
+}
diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs
new file mode 100644
index 0000000..9984094
--- /dev/null
+++ b/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs
@@ -0,0 +1,29 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+/// <inheritdoc />
+public class IsResetRequestValidRoute : RouteBaseAsync.WithRequest<Guid>.WithActionResult
+{
+ private readonly ForgotPasswordService _forgotPasswordService;
+
+ /// <inheritdoc />
+ public IsResetRequestValidRoute(ForgotPasswordService forgotPasswordService) {
+ _forgotPasswordService = forgotPasswordService;
+ }
+
+ /// <summary>
+ /// Check if a given password reset request is still valid.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/forgot-password-requests/is-valid")]
+ public override async Task<ActionResult> HandleAsync(Guid id, CancellationToken cancellationToken = default) {
+ var request = await _forgotPasswordService.GetRequestAsync(id, cancellationToken);
+ if (request == default) {
+ return NotFound();
+ }
+
+ return Ok(request.IsExpired == false);
+ }
+}
diff --git a/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs b/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs
new file mode 100644
index 0000000..5fb8213
--- /dev/null
+++ b/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs
@@ -0,0 +1,21 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class GetApplicationVersionRoute : RouteBaseSync.WithoutRequest.WithActionResult<string>
+{
+ private readonly IWebHostEnvironment _environment;
+
+ /// <inheritdoc />
+ public GetApplicationVersionRoute(IWebHostEnvironment environment) {
+ _environment = environment;
+ }
+
+ /// <summary>
+ /// Get the running api version number.
+ /// </summary>
+ /// <returns></returns>
+ [HttpGet("~/_/version")]
+ public override ActionResult<string> Handle() {
+ var versionFilePath = Path.Combine(_environment.WebRootPath, "version.txt");
+ return Ok(System.IO.File.ReadAllText(versionFilePath));
+ }
+}
diff --git a/server/src/Endpoints/Internal/Root/LogRoute.cs b/server/src/Endpoints/Internal/Root/LogRoute.cs
new file mode 100644
index 0000000..48b497a
--- /dev/null
+++ b/server/src/Endpoints/Internal/Root/LogRoute.cs
@@ -0,0 +1,16 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class LogRoute : RouteBaseSync.WithRequest<string>.WithoutResult
+{
+ private readonly ILogger<LogRoute> _logger;
+
+ public LogRoute(ILogger<LogRoute> logger) {
+ _logger = logger;
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/_/log")]
+ public override void Handle([FromBody] string request) {
+ _logger.LogInformation(request);
+ }
+}
diff --git a/server/src/Endpoints/Internal/RouteBaseAsync.cs b/server/src/Endpoints/Internal/RouteBaseAsync.cs
new file mode 100644
index 0000000..1bb0af0
--- /dev/null
+++ b/server/src/Endpoints/Internal/RouteBaseAsync.cs
@@ -0,0 +1,73 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseAsync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+}
diff --git a/server/src/Endpoints/Internal/RouteBaseSync.cs b/server/src/Endpoints/Internal/RouteBaseSync.cs
new file mode 100644
index 0000000..173999d
--- /dev/null
+++ b/server/src/Endpoints/Internal/RouteBaseSync.cs
@@ -0,0 +1,53 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseSync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle(TRequest request);
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle(TRequest request);
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle();
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle();
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle();
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle();
+ }
+ }
+}