aboutsummaryrefslogtreecommitdiffstats
path: root/code/api/src/Endpoints/Internal
diff options
context:
space:
mode:
Diffstat (limited to 'code/api/src/Endpoints/Internal')
-rw-r--r--code/api/src/Endpoints/Internal/Account/CreateAccountRoute.cs64
-rw-r--r--code/api/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs32
-rw-r--r--code/api/src/Endpoints/Internal/Account/DeleteAccountRoute.cs17
-rw-r--r--code/api/src/Endpoints/Internal/Account/GetAccountRoute.cs26
-rw-r--r--code/api/src/Endpoints/Internal/Account/LoginRoute.cs37
-rw-r--r--code/api/src/Endpoints/Internal/Account/LogoutRoute.cs22
-rw-r--r--code/api/src/Endpoints/Internal/Account/UpdateAccountRoute.cs59
-rw-r--r--code/api/src/Endpoints/Internal/Account/_calls.http9
-rw-r--r--code/api/src/Endpoints/Internal/INT_EndpointBase.cs9
-rw-r--r--code/api/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs40
-rw-r--r--code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs36
-rw-r--r--code/api/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs26
-rw-r--r--code/api/src/Endpoints/Internal/PasswordResetRequests/_calls.http22
-rw-r--r--code/api/src/Endpoints/Internal/Root/GetSessionRoute.cs64
-rw-r--r--code/api/src/Endpoints/Internal/Root/IsAuthenticatedRoute.cs10
-rw-r--r--code/api/src/Endpoints/Internal/Root/ReadConfigurationRoute.cs17
-rw-r--r--code/api/src/Endpoints/Internal/Root/RefreshConfigurationRoute.cs15
-rw-r--r--code/api/src/Endpoints/Internal/Root/ValidateRoute.cs39
-rw-r--r--code/api/src/Endpoints/Internal/RouteBaseAsync.cs73
-rw-r--r--code/api/src/Endpoints/Internal/RouteBaseSync.cs53
20 files changed, 670 insertions, 0 deletions
diff --git a/code/api/src/Endpoints/Internal/Account/CreateAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/CreateAccountRoute.cs
new file mode 100644
index 0000000..ee136a9
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Account/CreateAccountRoute.cs
@@ -0,0 +1,64 @@
+using ILogger = Microsoft.Extensions.Logging.ILogger;
+
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class CreateAccountRoute : RouteBaseAsync.WithRequest<CreateAccountRoute.Payload>.WithActionResult
+{
+ private readonly MainAppDatabase _database;
+ private readonly UserService _userService;
+ private readonly IStringLocalizer<SharedResources> _localizer;
+ private readonly EmailValidationService _emailValidation;
+ private readonly ILogger<CreateAccountRoute> _logger;
+ private readonly TenantService _tenantService;
+
+ public CreateAccountRoute(UserService userService, MainAppDatabase database, IStringLocalizer<SharedResources> localizer, EmailValidationService emailValidation, TenantService tenantService, ILogger<CreateAccountRoute> logger) {
+ _userService = userService;
+ _database = database;
+ _localizer = localizer;
+ _emailValidation = emailValidation;
+ _tenantService = tenantService;
+ _logger = logger;
+ }
+
+ public class Payload
+ {
+ public string Username { get; set; }
+ public string Password { get; set; }
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/_/account/create")]
+ public override async Task<ActionResult> HandleAsync(Payload request, CancellationToken cancellationToken = default) {
+ var problem = new KnownProblemModel();
+ var username = request.Username.Trim();
+ if (username.IsValidEmailAddress() == false) {
+ problem.AddError("username", _localizer["{username} does not look like a valid email", username]);
+ } else if (_database.Users.FirstOrDefault(c => c.Username == username) != default) {
+ problem.AddError("username", _localizer["There is already a user registered with username: {username}", username]);
+ }
+
+ if (request.Password.Length < 6) {
+ problem.AddError("password", _localizer["The password requires 6 or more characters."]);
+ }
+
+ if (problem.Errors.Any()) {
+ problem.Title = _localizer["Invalid form"];
+ problem.Subtitle = _localizer["One or more fields is invalid"];
+ return KnownProblem(problem);
+ }
+
+ var user = new User(username);
+ var tenant = _tenantService.CreateTenant(user.DisplayName() + "'s tenant", user.Id, user.Username);
+ if (tenant == default) {
+ _logger.LogError("Not creating new user because the tenant could not be created");
+ return KnownProblem(_localizer["Could not create your account, try again soon."]);
+ }
+
+ user.HashAndSetPassword(request.Password);
+ _database.Users.Add(user);
+ await _database.SaveChangesAsync(cancellationToken);
+ await _userService.LogInUserAsync(HttpContext, user, false, cancellationToken);
+ await _emailValidation.SendValidationEmailAsync(user);
+ return Ok();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs
new file mode 100644
index 0000000..e1d13dd
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs
@@ -0,0 +1,32 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class CreateInitialAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly MainAppDatabase _database;
+ private readonly UserService _userService;
+
+ public CreateInitialAccountRoute(MainAppDatabase database, UserService userService) {
+ _database = database;
+ _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 (_database.Users.Any()) {
+ return NotFound();
+ }
+
+ var user = new User("admin@ivarlovlie.no");
+ user.HashAndSetPassword("ivar123");
+ _database.Users.Add(user);
+ await _database.SaveChangesAsync(cancellationToken);
+ await _userService.LogInUserAsync(HttpContext, user, cancellationToken: cancellationToken);
+ return Redirect("/");
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Account/DeleteAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/DeleteAccountRoute.cs
new file mode 100644
index 0000000..f487f74
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Account/DeleteAccountRoute.cs
@@ -0,0 +1,17 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class DeleteAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly UserService _userService;
+
+ public DeleteAccountRoute(UserService userService) {
+ _userService = userService;
+ }
+
+ [HttpDelete("~/_/account/delete")]
+ public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) {
+ await _userService.LogOutUser(HttpContext, cancellationToken);
+ await _userService.MarkUserAsDeleted(LoggedInUser.Id, LoggedInUser.Id);
+ return Ok();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Account/GetAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/GetAccountRoute.cs
new file mode 100644
index 0000000..121b40f
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Account/GetAccountRoute.cs
@@ -0,0 +1,26 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class GetAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult<LoggedInUserModel>
+{
+ private readonly MainAppDatabase _database;
+
+ public GetAccountRoute(MainAppDatabase database) {
+ _database = database;
+ }
+
+ [HttpGet("~/_/account")]
+ public override async Task<ActionResult<LoggedInUserModel>> HandleAsync(CancellationToken cancellationToken = default) {
+ var user = _database.Users
+ .Select(x => new {x.Username, x.Id})
+ .SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user != default) {
+ return Ok(new LoggedInUserModel {
+ Id = LoggedInUser.Id,
+ Username = LoggedInUser.Username
+ });
+ }
+
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Account/LoginRoute.cs b/code/api/src/Endpoints/Internal/Account/LoginRoute.cs
new file mode 100644
index 0000000..703f324
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Account/LoginRoute.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class LoginRoute : RouteBaseAsync.WithRequest<LoginRoute.Payload>.WithActionResult
+{
+ private readonly MainAppDatabase _database;
+ private readonly UserService _userService;
+ private readonly IStringLocalizer<SharedResources> _localizer;
+
+ public LoginRoute(MainAppDatabase database, UserService userService, IStringLocalizer<SharedResources> localizer) {
+ _database = database;
+ _userService = userService;
+ _localizer = localizer;
+ }
+
+ public class Payload
+ {
+ public string Username { get; set; }
+ public string Password { get; set; }
+ public bool Persist { get; set; }
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/_/account/login")]
+ public override async Task<ActionResult> HandleAsync(Payload request, CancellationToken cancellationToken = default) {
+ var user = _database.Users.FirstOrDefault(u => u.Username == request.Username);
+ if (user == default || !user.VerifyPassword(request.Password)) {
+ return KnownProblem(_localizer["Invalid username or password"]);
+ }
+
+ if (user.Deleted) {
+ return KnownProblem(_localizer["This user is deleted, please contact support@greatoffice.life if you think this is an error"]);
+ }
+
+ await _userService.LogInUserAsync(HttpContext, user, request.Persist, cancellationToken);
+ return Ok();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Account/LogoutRoute.cs b/code/api/src/Endpoints/Internal/Account/LogoutRoute.cs
new file mode 100644
index 0000000..295d9f6
--- /dev/null
+++ b/code/api/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, cancellationToken);
+ return Ok();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Account/UpdateAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/UpdateAccountRoute.cs
new file mode 100644
index 0000000..1081240
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Account/UpdateAccountRoute.cs
@@ -0,0 +1,59 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class UpdateAccountRoute : RouteBaseAsync.WithRequest<UpdateAccountRoute.Payload>.WithActionResult
+{
+ private readonly MainAppDatabase _database;
+ private readonly IStringLocalizer<SharedResources> _localizer;
+
+ public UpdateAccountRoute(MainAppDatabase database, IStringLocalizer<SharedResources> localizer) {
+ _database = database;
+ _localizer = localizer;
+ }
+
+ public class Payload
+ {
+ public string Username { get; set; }
+
+ public string Password { get; set; }
+ }
+
+ [HttpPost("~/_/account/update")]
+ public override async Task<ActionResult> HandleAsync(Payload request, CancellationToken cancellationToken = default) {
+ var user = _database.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user == default) {
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+
+ if (request.Password.IsNullOrWhiteSpace() && request.Username.IsNullOrWhiteSpace()) {
+ return KnownProblem(_localizer["Invalid request"], _localizer["No data was submitted"]);
+ }
+
+ var problem = new KnownProblemModel();
+
+ if (request.Password.HasValue() && request.Password.Length < 6) {
+ problem.AddError("password", _localizer["The new password must contain at least 6 characters"]);
+ }
+
+ if (request.Password.HasValue()) {
+ user.HashAndSetPassword(request.Password);
+ }
+
+ if (request.Username.HasValue() && !request.Username.IsValidEmailAddress()) {
+ problem.AddError("username", _localizer["The new username does not look like a valid email address"]);
+ }
+
+ if (problem.Errors.Any()) {
+ problem.Title = _localizer["Invalid form"];
+ problem.Subtitle = _localizer["One or more validation errors occured"];
+ return KnownProblem(problem);
+ }
+
+ if (request.Username.HasValue()) {
+ user.Username = request.Username.Trim();
+ }
+
+ await _database.SaveChangesAsync(cancellationToken);
+ return Ok();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Account/_calls.http b/code/api/src/Endpoints/Internal/Account/_calls.http
new file mode 100644
index 0000000..78380f5
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Account/_calls.http
@@ -0,0 +1,9 @@
+### Login
+POST http://localhost:5000/_/account/login
+Content-Type: application/json
+
+{
+ "username": "i@oiee.no",
+ "password": "ivar123",
+ "persist": false
+}
diff --git a/code/api/src/Endpoints/Internal/INT_EndpointBase.cs b/code/api/src/Endpoints/Internal/INT_EndpointBase.cs
new file mode 100644
index 0000000..699a976
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/INT_EndpointBase.cs
@@ -0,0 +1,9 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal;
+
+[Authorize]
+[ApiExplorerSettings(IgnoreApi = true)]
+[ApiVersionNeutral]
+public class INT_EndpointBase : EndpointBase
+{
+
+}
diff --git a/code/api/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs b/code/api/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs
new file mode 100644
index 0000000..c6ed417
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs
@@ -0,0 +1,40 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+public class CreateResetRequestRoute : RouteBaseAsync.WithRequest<CreateResetRequestRoute.Payload>.WithActionResult
+{
+ private readonly ILogger<CreateResetRequestRoute> _logger;
+ private readonly PasswordResetService _passwordResetService;
+ private readonly MainAppDatabase _database;
+ private readonly IStringLocalizer<SharedResources> _localizer;
+
+ public CreateResetRequestRoute(ILogger<CreateResetRequestRoute> logger, PasswordResetService passwordResetService, MainAppDatabase database, IStringLocalizer<SharedResources> localizer) {
+ _logger = logger;
+ _passwordResetService = passwordResetService;
+ _database = database;
+ _localizer = localizer;
+ }
+
+ public class Payload
+ {
+ public string Email { get; set; }
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/_/password-reset-request/create")]
+ public override async Task<ActionResult> HandleAsync(Payload payload, CancellationToken cancellationToken = default) {
+ if (payload.Email.IsNullOrWhiteSpace()) {
+ return KnownProblem(_localizer["Invalid form"],
+ _localizer["One or more fields is invalid"],
+ new() {{"email", new string[] {_localizer["Email is a required field"]}}}
+ );
+ }
+
+ var tz = GetRequestTimeZone(_logger);
+ _logger.LogInformation("Creating forgot password request with local date time: " + tz.LocalDateTime.ToString("u"));
+ var user = _database.Users.FirstOrDefault(c => c.Username.Equals(payload.Email));
+ // Don't inform the caller that the user does not exist.
+ if (user == default) return Ok();
+ await _passwordResetService.AddRequestAsync(user, tz.TimeZoneInfo, cancellationToken);
+ return Ok();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs b/code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs
new file mode 100644
index 0000000..9cd92bb
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs
@@ -0,0 +1,36 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+public class FulfillResetRequestRoute : RouteBaseAsync.WithRequest<FulfillResetRequestRoute.Payload>.WithActionResult
+{
+ private readonly IStringLocalizer<SharedResources> _localizer;
+ private readonly PasswordResetService _passwordResetService;
+
+ public FulfillResetRequestRoute(PasswordResetService passwordResetService, IStringLocalizer<SharedResources> localizer) {
+ _passwordResetService = passwordResetService;
+ _localizer = localizer;
+ }
+
+ public class Payload
+ {
+ public Guid Id { get; set; }
+ public string NewPassword { get; set; }
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/_/password-reset-request/fulfill")]
+ public override async Task<ActionResult> HandleAsync(Payload request, CancellationToken cancellationToken = default) {
+ if (request.NewPassword.Length < 6) {
+ return KnownProblem(_localizer["Invalid form"],
+ _localizer["One or more fields is invalid"],
+ new Dictionary<string, string[]> {{"newPassword", new string[] {_localizer["The new password needs to be atleast 6 characters"]}}}
+ );
+ }
+
+ return await _passwordResetService.FulfillRequestAsync(request.Id, request.NewPassword, cancellationToken) switch {
+ FulfillPasswordResetRequestResult.REQUEST_NOT_FOUND => NotFound(),
+ FulfillPasswordResetRequestResult.USER_NOT_FOUND => NotFound(),
+ FulfillPasswordResetRequestResult.FULFILLED => Ok(),
+ _ => StatusCode(500)
+ };
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs b/code/api/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs
new file mode 100644
index 0000000..a87c0a9
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs
@@ -0,0 +1,26 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+public class IsResetRequestValidRoute : RouteBaseAsync.WithRequest<Guid>.WithActionResult<IsResetRequestValidRoute.ResponseModel>
+{
+ private readonly PasswordResetService _passwordResetService;
+
+ public IsResetRequestValidRoute(PasswordResetService passwordResetService) {
+ _passwordResetService = passwordResetService;
+ }
+
+ public class ResponseModel
+ {
+ public ResponseModel(bool isValid) {
+ IsValid = isValid;
+ }
+
+ public bool IsValid { get; }
+ }
+
+ [AllowAnonymous]
+ [HttpGet("~/_/password-reset-request/is-valid")]
+ public override async Task<ActionResult<ResponseModel>> HandleAsync(Guid id, CancellationToken cancellationToken = default) {
+ var request = await _passwordResetService.GetRequestAsync(id, cancellationToken);
+ return Ok(request == default ? new ResponseModel(false) : new ResponseModel(!request.IsExpired));
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/PasswordResetRequests/_calls.http b/code/api/src/Endpoints/Internal/PasswordResetRequests/_calls.http
new file mode 100644
index 0000000..cfd2d58
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/PasswordResetRequests/_calls.http
@@ -0,0 +1,22 @@
+### Create request
+POST http://localhost:5000/_/password-reset-request/create
+Accept: application/json
+Content-Type: application/json
+
+{
+ "email": ""
+}
+
+### Fulfill request
+POST http://localhost:5000/_/password-reset-request/fulfill
+Accept: application/json
+Content-Type: application/json
+
+{
+ "id": "",
+ "newPassword": ""
+}
+
+### Is request valid
+GET http://localhost:5000/_/password-reset-request/is-valid?id=
+Accept: application/json
diff --git a/code/api/src/Endpoints/Internal/Root/GetSessionRoute.cs b/code/api/src/Endpoints/Internal/Root/GetSessionRoute.cs
new file mode 100644
index 0000000..82bbb11
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Root/GetSessionRoute.cs
@@ -0,0 +1,64 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class GetSessionRoute : RouteBaseSync.WithoutRequest.WithActionResult<GetSessionRoute.SessionResponse>
+{
+ private readonly MainAppDatabase _database;
+ private readonly ILogger<GetSessionRoute> _logger;
+
+ public GetSessionRoute(MainAppDatabase database, ILogger<GetSessionRoute> logger) {
+ _database = database;
+ _logger = logger;
+ }
+
+ public class SessionResponse
+ {
+ public string Username { get; set; }
+ public string DisplayName { get; set; }
+ public Guid UserId { get; set; }
+ public SessionTenant CurrentTenant { get; set; }
+ public List<SessionTenant> AvailableTenants { get; set; }
+
+ public class SessionTenant
+ {
+ public Guid Id { get; set; }
+ public string Name { get; set; }
+ }
+ }
+
+ [Authorize]
+ [HttpGet("~/_/session-data")]
+ public override ActionResult<SessionResponse> Handle() {
+ var user = _database.Users.Include(c => c.Tenants)
+ .Select(c => new User() {
+ Id = c.Id,
+ Username = c.Username,
+ FirstName = c.FirstName,
+ LastName = c.LastName,
+ Tenants = c.Tenants
+ }).FirstOrDefault(c => c.Id == LoggedInUser.Id);
+
+ if (user == default) {
+ return NotFound();
+ }
+
+ var currentTenant = user.Tenants.FirstOrDefault(c => c.Id == LoggedInUser.TenantId);
+ if (currentTenant == default) {
+ _logger.LogInformation("Could not find current tenant ({tenantId}) for user {userId}", LoggedInUser.TenantId, LoggedInUser.Id);
+ return NotFound();
+ }
+
+ return Ok(new SessionResponse() {
+ Username = user.Username,
+ DisplayName = user.DisplayName(),
+ UserId = user.Id,
+ CurrentTenant = new SessionResponse.SessionTenant() {
+ Id = currentTenant.Id,
+ Name = currentTenant.Name
+ },
+ AvailableTenants = user.Tenants.Select(c => new SessionResponse.SessionTenant() {
+ Id = c.Id,
+ Name = c.Name
+ }).ToList()
+ });
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Root/IsAuthenticatedRoute.cs b/code/api/src/Endpoints/Internal/Root/IsAuthenticatedRoute.cs
new file mode 100644
index 0000000..7bb0a86
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Root/IsAuthenticatedRoute.cs
@@ -0,0 +1,10 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class IsAuthenticatedRoute : RouteBaseSync.WithoutRequest.WithActionResult
+{
+ [Authorize]
+ [HttpGet("~/_/is-authenticated")]
+ public override ActionResult Handle() {
+ return Ok();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Root/ReadConfigurationRoute.cs b/code/api/src/Endpoints/Internal/Root/ReadConfigurationRoute.cs
new file mode 100644
index 0000000..7270fd8
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Root/ReadConfigurationRoute.cs
@@ -0,0 +1,17 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class ReadConfigurationRoute : RouteBaseSync.WithoutRequest.WithActionResult
+{
+ private readonly VaultService _vaultService;
+
+ public ReadConfigurationRoute(VaultService vaultService) {
+ _vaultService = vaultService;
+ }
+
+ [AllowAnonymous]
+ [HttpGet("~/_/configuration")]
+ public override ActionResult Handle() {
+ var config = _vaultService.GetCurrentAppConfiguration();
+ return Content(JsonSerializer.Serialize(config.GetPublicVersion()), "application/json");
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Root/RefreshConfigurationRoute.cs b/code/api/src/Endpoints/Internal/Root/RefreshConfigurationRoute.cs
new file mode 100644
index 0000000..fde4832
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Root/RefreshConfigurationRoute.cs
@@ -0,0 +1,15 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class RefreshConfigurationRoute : RouteBaseSync.WithoutRequest.WithoutResult
+{
+ private readonly VaultService _vaultService;
+
+ public RefreshConfigurationRoute(VaultService vaultService) {
+ _vaultService = vaultService;
+ }
+
+ [HttpGet("~/_/refresh-configuration")]
+ public override void Handle() {
+ _vaultService.RefreshCurrentAppConfigurationAsync();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Root/ValidateRoute.cs b/code/api/src/Endpoints/Internal/Root/ValidateRoute.cs
new file mode 100644
index 0000000..8f0882d
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Root/ValidateRoute.cs
@@ -0,0 +1,39 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class ValidateRoute : RouteBaseSync.WithRequest<ValidateRoute.QueryParams>.WithActionResult
+{
+ private readonly EmailValidationService _emailValidation;
+ private readonly string CanonicalFrontendUrl;
+ private readonly ILogger<ValidateRoute> _logger;
+
+ public ValidateRoute(VaultService vaultService, EmailValidationService emailValidation, ILogger<ValidateRoute> logger) {
+ _emailValidation = emailValidation;
+ _logger = logger;
+ var c = vaultService.GetCurrentAppConfiguration();
+ CanonicalFrontendUrl = c.CANONICAL_FRONTEND_URL;
+ }
+
+ public class QueryParams
+ {
+ [FromQuery]
+ public Guid Id { get; set; }
+ }
+
+ [HttpGet("~/_/validate")]
+ public override ActionResult Handle([FromQuery] QueryParams request) {
+ var isFulfilled = _emailValidation.FulfillEmailValidationRequest(request.Id, LoggedInUser.Id);
+ if (!isFulfilled) {
+ _logger.LogError("Email validation fulfillment failed for request {requestId} and user {userId}", request.Id, LoggedInUser.Id);
+ return StatusCode(400, $"""
+<html>
+<body>
+<h3>The validation could not be completed</h3>
+<p>We are working on fixing this, in the meantime, have patience.</p>
+<a href="{CanonicalFrontendUrl}">Click here to go back to {CanonicalFrontendUrl}</a>
+</body>
+""");
+ }
+
+ return Redirect(CanonicalFrontendUrl + "/portal?msg=emailValidated");
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/RouteBaseAsync.cs b/code/api/src/Endpoints/Internal/RouteBaseAsync.cs
new file mode 100644
index 0000000..a87facf
--- /dev/null
+++ b/code/api/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> : INT_EndpointBase
+ {
+ public abstract Task<TResponse> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : INT_EndpointBase
+ {
+ public abstract Task HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : INT_EndpointBase
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : INT_EndpointBase
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : INT_EndpointBase
+ {
+ public abstract Task<TResponse> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : INT_EndpointBase
+ {
+ public abstract Task HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : INT_EndpointBase
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : INT_EndpointBase
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+}
diff --git a/code/api/src/Endpoints/Internal/RouteBaseSync.cs b/code/api/src/Endpoints/Internal/RouteBaseSync.cs
new file mode 100644
index 0000000..9d9bd5a
--- /dev/null
+++ b/code/api/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> : INT_EndpointBase
+ {
+ public abstract TResponse Handle(TRequest request);
+ }
+
+ public abstract class WithoutResult : INT_EndpointBase
+ {
+ public abstract void Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult<TResponse> : INT_EndpointBase
+ {
+ public abstract ActionResult<TResponse> Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult : INT_EndpointBase
+ {
+ public abstract ActionResult Handle(TRequest request);
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : INT_EndpointBase
+ {
+ public abstract TResponse Handle();
+ }
+
+ public abstract class WithoutResult : INT_EndpointBase
+ {
+ public abstract void Handle();
+ }
+
+ public abstract class WithActionResult<TResponse> : INT_EndpointBase
+ {
+ public abstract ActionResult<TResponse> Handle();
+ }
+
+ public abstract class WithActionResult : INT_EndpointBase
+ {
+ public abstract ActionResult Handle();
+ }
+ }
+}