diff --git a/.dockerignore b/.dockerignore
index 2d15e48..3729ff0 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,20 +1,25 @@
+**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
**/azds.yaml
-**/charts
**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
**/obj
-**/Dockerfile
-**/Dockerfile.develop
-**/docker-compose.yml
-**/docker-compose.*.yml
-**/*.dbmdl
-**/*.jfm
**/secrets.dev.yaml
**/values.dev.yaml
-**/.toolstarget
\ No newline at end of file
+LICENSE
+README.md
\ No newline at end of file
diff --git a/WebApp/doorrequest/.editorconfig b/.editorconfig
similarity index 88%
rename from WebApp/doorrequest/.editorconfig
rename to .editorconfig
index f43e844..d3b8cfc 100644
--- a/WebApp/doorrequest/.editorconfig
+++ b/.editorconfig
@@ -119,13 +119,13 @@ csharp_prefer_static_local_function = true:warning
csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async
# Code-block preferences
-csharp_prefer_simple_using_statement = true
+csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = when_multiline:warning
# Expression-level preferences
csharp_prefer_simple_default_expression = true:warning
csharp_style_deconstructed_variable_declaration = true
-csharp_style_implicit_object_creation_when_type_is_apparent = true
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = false
csharp_style_pattern_local_over_anonymous_function = true:warning
csharp_style_prefer_index_operator = true:warning
@@ -264,3 +264,31 @@ dotnet_naming_style.generic_parameter_style.required_prefix = T
dotnet_naming_style.generic_parameter_style.required_suffix =
dotnet_naming_style.generic_parameter_style.word_separator =
dotnet_naming_style.generic_parameter_style.capitalization = pascal_case
+csharp_style_prefer_method_group_conversion = true:silent
+csharp_style_prefer_top_level_statements = true:silent
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+
+[*.{cs,vb}]
+dotnet_style_coalesce_expression = true:warning
+dotnet_style_null_propagation = true:warning
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_object_initializer = true:warning
+dotnet_style_collection_initializer = true:warning
+dotnet_style_prefer_simplified_boolean_expressions = true:warning
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_style_explicit_tuple_names = true:warning
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:warning
+dotnet_style_namespace_match_folder = true:suggestion
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+tab_width = 4
+indent_size = 4
+end_of_line = crlf
\ No newline at end of file
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index 6961638..d316a40 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -3,15 +3,21 @@ on:
push:
branches: [master]
-env:
- REGISTRY: ghcr.io
-
jobs:
publish:
+ name: Publish ${{ matrix.image }}
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - dockerfile: ./API/DoorRequest.API/Dockerfile
+ image: ghcr.io/brixel/door-request-api
+ - dockerfile: ./Web/Dockerfile
+ image: ghcr.io/brixel/door-request-web
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -20,7 +26,7 @@ jobs:
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
- registry: ${{ env.REGISTRY }}
+ registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install GitVersion
@@ -34,14 +40,14 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
- images: ${{ env.REGISTRY }}/brixel/door-request-api
+ images: ${{ matrix.image }}
tags: type=semver,pattern={{version}},value=${{ steps.gitversion.outputs.semVer }}
- - name: Build and push
- id: docker_build
+ - name: Build and push docker image
+ id: docker_build_api
uses: docker/build-push-action@v5
with:
context: .
push: true
- file: API/DoorRequest.API/Dockerfile
+ file: ${{ matrix.dockerfile }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
diff --git a/API/DoorRequest.API/Controllers/DoorRequestController.cs b/API/DoorRequest.API/Controllers/DoorRequestController.cs
index 3b508a6..bed6cca 100644
--- a/API/DoorRequest.API/Controllers/DoorRequestController.cs
+++ b/API/DoorRequest.API/Controllers/DoorRequestController.cs
@@ -1,9 +1,13 @@
-using DoorRequest.API.Config;
+using System.Threading.Tasks;
+
+using DoorRequest.API.Config;
using DoorRequest.API.Services;
+
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
-using System.Threading.Tasks;
+
+using Shared.Authorization;
namespace DoorRequest.API.Controllers;
@@ -12,24 +16,24 @@ namespace DoorRequest.API.Controllers;
[Authorize]
public class DoorRequestController : ControllerBase
{
- private readonly IDoorRequestService _doorRequestService;
+ private readonly IDoorService _doorService;
private readonly LockConfiguration _lockConfiguration;
- public DoorRequestController(IDoorRequestService doorRequestService, IOptions lockConfiguration)
+ public DoorRequestController(IDoorService doorService, IOptions lockConfiguration)
{
- _doorRequestService = doorRequestService;
+ _doorService = doorService ?? throw new System.ArgumentNullException(nameof(doorService));
_lockConfiguration = lockConfiguration.Value;
}
[HttpPost("open")]
- [Authorize(Roles = Authorization.Roles.TwentyFourSevenAccess)]
+ [Authorize(Roles = Roles.TwentyFourSevenAccess)]
public async Task OpenDoorRequest()
{
- return await _doorRequestService.OpenDoor();
+ return await _doorService.OpenDoor();
}
[HttpGet("code")]
- [Authorize(Roles = Authorization.Roles.KeyVaultCodeAccess)]
+ [Authorize(Roles = Roles.KeyVaultCodeAccess)]
public int GetLockCode()
{
return _lockConfiguration.Code;
diff --git a/API/DoorRequest.API/DoorRequest.API.csproj b/API/DoorRequest.API/DoorRequest.API.csproj
index 83bdbc7..19236bb 100644
--- a/API/DoorRequest.API/DoorRequest.API.csproj
+++ b/API/DoorRequest.API/DoorRequest.API.csproj
@@ -22,6 +22,10 @@
+
+
+
+
diff --git a/API/DoorRequest.API/Health.http b/API/DoorRequest.API/Health.http
new file mode 100644
index 0000000..488bb30
--- /dev/null
+++ b/API/DoorRequest.API/Health.http
@@ -0,0 +1,6 @@
+# For more info on HTTP files go to https://aka.ms/vs/httpfile
+@rootUrl=https://localhost:5001
+
+HEAD {{rootUrl}}/healthz
+
+# HEAD {{rootUrl}}/healthz
\ No newline at end of file
diff --git a/API/DoorRequest.API/Program.cs b/API/DoorRequest.API/Program.cs
index ca06168..9b3fcf6 100644
--- a/API/DoorRequest.API/Program.cs
+++ b/API/DoorRequest.API/Program.cs
@@ -1,6 +1,9 @@
-using DoorRequest.API.Authorization;
+using System;
+using System.IdentityModel.Tokens.Jwt;
+
using DoorRequest.API.Config;
using DoorRequest.API.Services;
+
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
@@ -9,10 +12,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
+
using Serilog;
using Serilog.Events;
-using System;
-using System.IdentityModel.Tokens.Jwt;
+
+using Shared.Authorization;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
@@ -33,6 +37,7 @@
builder.Services.AddSwaggerGen();
builder.Services.AddControllers();
+ builder.Services.AddHealthChecks();
var authOptions = builder.Configuration.GetSection(AuthenticationConfiguration.SectionName).Get();
@@ -68,22 +73,12 @@
.Build();
});
- builder.Services.AddCors(options =>
- {
- //TODO: Fix CORS
- options.AddPolicy("CorsPolicy", builder =>
- builder.AllowAnyOrigin()
- .AllowAnyMethod()
- .AllowAnyHeader());
- });
-
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection(DoorConfiguration.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
- builder.Services.AddScoped();
- builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.Configure(builder.Configuration.GetSection(nameof(LockConfiguration)));
var app = builder.Build();
@@ -96,12 +91,17 @@
}
app.UseHttpsRedirection();
- app.UseCors("CorsPolicy");
+ app.UseCors(builder =>
+ //TODO: Fix CORS
+ builder.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader());
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
+ app.MapHealthChecks("/healthz");
app.Run();
}
diff --git a/API/DoorRequest.API/Properties/launchSettings.json b/API/DoorRequest.API/Properties/launchSettings.json
index a328f99..c733f44 100644
--- a/API/DoorRequest.API/Properties/launchSettings.json
+++ b/API/DoorRequest.API/Properties/launchSettings.json
@@ -1,22 +1,5 @@
{
- "iisSettings": {
- "windowsAuthentication": false,
- "anonymousAuthentication": true,
- "iisExpress": {
- "applicationUrl": "http://localhost:63559",
- "sslPort": 44300
- }
- },
- "$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
- "IIS Express": {
- "commandName": "IISExpress",
- "launchBrowser": true,
- "launchUrl": "api/values",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
"DoorRequest.API": {
"commandName": "Project",
"launchUrl": "api/values",
@@ -37,5 +20,14 @@
"useSSL": true,
"sslPort": 44301
}
+ },
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:63559",
+ "sslPort": 44300
+ }
}
}
\ No newline at end of file
diff --git a/API/DoorRequest.API/Services/DoorRequestService.cs b/API/DoorRequest.API/Services/DoorRequestService.cs
deleted file mode 100644
index 16c3878..0000000
--- a/API/DoorRequest.API/Services/DoorRequestService.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using Microsoft.Extensions.Logging;
-using System.Threading.Tasks;
-
-namespace DoorRequest.API.Services;
-
-public class DoorRequestService : IDoorRequestService
-{
- private readonly IBrixelOpenDoorClient _brixelOpenDoorClient;
- private readonly ILogger _logger;
-
- public DoorRequestService(IBrixelOpenDoorClient brixelOpenDoorClient, ILogger logger)
- {
- _brixelOpenDoorClient = brixelOpenDoorClient;
- _logger = logger;
- }
- public async Task OpenDoor()
- {
- _logger.LogInformation("Sending request to open the door via MQTT");
- return await _brixelOpenDoorClient.OpenDoor();
- }
-}
diff --git a/API/DoorRequest.API/Services/BrixelOpenDoorClient.cs b/API/DoorRequest.API/Services/DoorService.cs
similarity index 84%
rename from API/DoorRequest.API/Services/BrixelOpenDoorClient.cs
rename to API/DoorRequest.API/Services/DoorService.cs
index 4d1d8c7..c4ce5ad 100644
--- a/API/DoorRequest.API/Services/BrixelOpenDoorClient.cs
+++ b/API/DoorRequest.API/Services/DoorService.cs
@@ -1,5 +1,6 @@
using DoorRequest.API.Config;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MQTTnet;
using MQTTnet.Client;
@@ -11,12 +12,13 @@
namespace DoorRequest.API.Services;
-public class BrixelOpenDoorClient : IBrixelOpenDoorClient
+public class DoorService : IDoorService
{
private readonly string _topic;
private readonly MqttClientOptions _options;
+ private readonly ILogger _logger;
- public BrixelOpenDoorClient(IOptions options)
+ public DoorService(IOptions options, ILogger logger)
{
_topic = options.Value.Topic;
var optionsBuilder = new MqttClientOptionsBuilder()
@@ -44,10 +46,12 @@ public BrixelOpenDoorClient(IOptions options)
}
_options = optionsBuilder.Build();
+ _logger = logger ?? throw new System.ArgumentNullException(nameof(logger));
}
public async Task OpenDoor()
{
+ _logger.LogInformation("Sending request to open the door via MQTT");
using var mqttClient = new MqttFactory().CreateMqttClient();
await mqttClient.ConnectAsync(_options, CancellationToken.None);
var message = new MqttApplicationMessageBuilder()
diff --git a/API/DoorRequest.API/Services/IBrixelOpenDoorClient.cs b/API/DoorRequest.API/Services/IBrixelOpenDoorClient.cs
deleted file mode 100644
index 3dcfc9b..0000000
--- a/API/DoorRequest.API/Services/IBrixelOpenDoorClient.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using System.Threading.Tasks;
-
-namespace DoorRequest.API.Services;
-
-public interface IBrixelOpenDoorClient
-{
- Task OpenDoor();
-}
\ No newline at end of file
diff --git a/API/DoorRequest.API/Services/IDoorRequestService.cs b/API/DoorRequest.API/Services/IDoorService.cs
similarity index 73%
rename from API/DoorRequest.API/Services/IDoorRequestService.cs
rename to API/DoorRequest.API/Services/IDoorService.cs
index 27dc5af..ce39028 100644
--- a/API/DoorRequest.API/Services/IDoorRequestService.cs
+++ b/API/DoorRequest.API/Services/IDoorService.cs
@@ -2,7 +2,7 @@
namespace DoorRequest.API.Services;
-public interface IDoorRequestService
+public interface IDoorService
{
Task OpenDoor();
}
\ No newline at end of file
diff --git a/API/DoorRequest.API/appsettings.Development.json b/API/DoorRequest.API/appsettings.Development.json
index e4846b9..49ccd61 100644
--- a/API/DoorRequest.API/appsettings.Development.json
+++ b/API/DoorRequest.API/appsettings.Development.json
@@ -21,10 +21,13 @@
"Authentication": {
"Authority": "http://localhost:5302/realms/DevRealm"
},
- "MQTTDoorConfiguration": {
- "Server": "localhost",
- "Port": 5303,
- "UseSSL": false,
- "Topic": "some/topic"
- }
+ "MQTTDoorConfiguration": {
+ "Server": "localhost",
+ "Port": 5303,
+ "UseSSL": false,
+ "Topic": "some/topic"
+ },
+ "LockConfiguration": {
+ "Code": 123
+ }
}
diff --git a/DoorRequest.sln b/DoorRequest.sln
index 41a3feb..aedccff 100644
--- a/DoorRequest.sln
+++ b/DoorRequest.sln
@@ -1,13 +1,17 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.6.33723.286
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DoorRequest.API", "API\DoorRequest.API\DoorRequest.API.csproj", "{263FC65F-673F-42AF-BBDC-7D194BCF58AC}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Web", "Web\Web.csproj", "{D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution items", "Solution items", "{CC45F04F-0025-46C0-B8D9-5072C68FD717}"
ProjectSection(SolutionItems) = preProject
+ .editorconfig = .editorconfig
docker-compose.yml = docker-compose.yml
+ GitVersion.yml = GitVersion.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{1CD813E1-F654-4340-8DCA-0935FBBA9EE2}"
@@ -22,6 +26,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mosquitto", "Mosquitto", "{
Infrastructure\Mosquitto\config\mosquitto.conf = Infrastructure\Mosquitto\config\mosquitto.conf
EndProjectSection
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared", "Shared\Shared.csproj", "{51C4CCF5-F70A-45CF-BF76-9DF83B92E731}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -32,10 +38,22 @@ Global
{263FC65F-673F-42AF-BBDC-7D194BCF58AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{263FC65F-673F-42AF-BBDC-7D194BCF58AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{263FC65F-673F-42AF-BBDC-7D194BCF58AC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {51C4CCF5-F70A-45CF-BF76-9DF83B92E731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {51C4CCF5-F70A-45CF-BF76-9DF83B92E731}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {51C4CCF5-F70A-45CF-BF76-9DF83B92E731}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {51C4CCF5-F70A-45CF-BF76-9DF83B92E731}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {C695FD5A-7CC3-4226-A8C4-7C89EA489FA6} = {1CD813E1-F654-4340-8DCA-0935FBBA9EE2}
+ {13A6DFE6-8ADB-4493-91C3-48803473E3E1} = {1CD813E1-F654-4340-8DCA-0935FBBA9EE2}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3BEE30D4-3A4A-4C6F-BA05-29AF80363B10}
EndGlobalSection
diff --git a/API/DoorRequest.API/Authorization/CustomClaims.cs b/Shared/Authorization/CustomClaims.cs
similarity index 65%
rename from API/DoorRequest.API/Authorization/CustomClaims.cs
rename to Shared/Authorization/CustomClaims.cs
index b1dbf0b..11b1def 100644
--- a/API/DoorRequest.API/Authorization/CustomClaims.cs
+++ b/Shared/Authorization/CustomClaims.cs
@@ -1,4 +1,4 @@
-namespace DoorRequest.API.Authorization;
+namespace Shared.Authorization;
public static class CustomClaims
{
diff --git a/API/DoorRequest.API/Authorization/Roles.cs b/Shared/Authorization/Roles.cs
similarity index 81%
rename from API/DoorRequest.API/Authorization/Roles.cs
rename to Shared/Authorization/Roles.cs
index 89458cd..83a25c9 100644
--- a/API/DoorRequest.API/Authorization/Roles.cs
+++ b/Shared/Authorization/Roles.cs
@@ -1,4 +1,4 @@
-namespace DoorRequest.API.Authorization;
+namespace Shared.Authorization;
public static class Roles
{
diff --git a/Shared/Shared.csproj b/Shared/Shared.csproj
new file mode 100644
index 0000000..132c02c
--- /dev/null
+++ b/Shared/Shared.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net6.0
+ enable
+ enable
+
+
+
diff --git a/Web/App.razor b/Web/App.razor
new file mode 100644
index 0000000..e5794c4
--- /dev/null
+++ b/Web/App.razor
@@ -0,0 +1,36 @@
+@using Web.Services;
+@inject IConnectionStatusService ConnectionStatusService;
+
+
+
+
+
+
+ @if (context.User.Identity?.IsAuthenticated != true)
+ {
+
+ Please log in
+ }
+ else
+ {
+ You are not authorized to access this resource.
+ }
+
+
+
+
+
+ Not found
+
+ Sorry, there's nothing at this address.
+
+
+
+
+
+@code {
+ protected override async Task OnInitializedAsync()
+ {
+ await ConnectionStatusService.StartPeriodicChecks(CancellationToken.None);
+ }
+}
\ No newline at end of file
diff --git a/Web/Authorization/CustomUserFactory.cs b/Web/Authorization/CustomUserFactory.cs
new file mode 100644
index 0000000..e584bfa
--- /dev/null
+++ b/Web/Authorization/CustomUserFactory.cs
@@ -0,0 +1,48 @@
+using System.Security.Claims;
+using System.Text.Json;
+
+using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
+using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
+
+namespace Web.Authorization;
+
+public class CustomUserFactory : AccountClaimsPrincipalFactory
+{
+ public CustomUserFactory(IAccessTokenProviderAccessor accessor)
+ : base(accessor)
+ {
+ }
+
+ public async override ValueTask CreateUserAsync(
+ RemoteUserAccount account,
+ RemoteAuthenticationUserOptions options)
+ {
+ var user = await base.CreateUserAsync(account, options);
+ var claimsIdentity = (ClaimsIdentity)(user.Identity
+ ?? throw new Exception("Could not get user's Identity property"));
+
+ if (account != null)
+ {
+ MapArrayClaimsToMultipleSeparateClaims(account, claimsIdentity);
+ }
+
+ return user;
+ }
+
+ private static void MapArrayClaimsToMultipleSeparateClaims(RemoteUserAccount account, ClaimsIdentity claimsIdentity)
+ {
+ foreach (var prop in account.AdditionalProperties)
+ {
+ var key = prop.Key;
+ var value = prop.Value;
+ if (value != null &&
+ (value is JsonElement element && element.ValueKind == JsonValueKind.Array))
+ {
+ claimsIdentity.RemoveClaim(claimsIdentity.FindFirst(prop.Key));
+ var claims = element.EnumerateArray()
+ .Select(x => new Claim(prop.Key, x.ToString()));
+ claimsIdentity.AddClaims(claims);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Web/Configuration/ApiConfiguration.cs b/Web/Configuration/ApiConfiguration.cs
new file mode 100644
index 0000000..554f7b7
--- /dev/null
+++ b/Web/Configuration/ApiConfiguration.cs
@@ -0,0 +1,11 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Web.Configuration;
+
+public class ApiConfiguration
+{
+ public const string SectionName = "ApiConfiguration";
+
+ [Url, Required(AllowEmptyStrings = false)]
+ public string BaseUrl { get; set; } = default!;
+}
diff --git a/Web/Dockerfile b/Web/Dockerfile
new file mode 100644
index 0000000..efda937
--- /dev/null
+++ b/Web/Dockerfile
@@ -0,0 +1,17 @@
+FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env
+#RUN apk add nodejs
+#RUN apk add npm
+RUN apt-get update && apt-get install -y python3
+WORKDIR /app
+COPY . ./
+RUN dotnet workload restore
+#RUN npm --prefix Web install
+RUN dotnet build "Web/Web.csproj" -c Release
+RUN dotnet publish "Web/Web.csproj" -c Release -o output
+RUN rm output/wwwroot/appsettings.json.br && rm output/wwwroot/appsettings.json.gz
+
+FROM nginx:alpine
+WORKDIR /usr/share/nginx/html
+COPY --from=build-env /app/output/wwwroot .
+COPY Web/nginx.conf /etc/nginx/nginx.conf
+EXPOSE 80
\ No newline at end of file
diff --git a/Web/Extensions/ApiOptionsExtensions.cs b/Web/Extensions/ApiOptionsExtensions.cs
new file mode 100644
index 0000000..0ea38e5
--- /dev/null
+++ b/Web/Extensions/ApiOptionsExtensions.cs
@@ -0,0 +1,15 @@
+using Web.Configuration;
+
+namespace Web.Extensions;
+
+public static class ApiOptionsExtensions
+{
+ public static IServiceCollection ConfigureApiOptions(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddOptions()
+ .Bind(configuration.GetSection(ApiConfiguration.SectionName))
+ .ValidateDataAnnotations();
+
+ return services;
+ }
+}
diff --git a/Web/Extensions/ApiServiceExtensions.cs b/Web/Extensions/ApiServiceExtensions.cs
new file mode 100644
index 0000000..5fa3f14
--- /dev/null
+++ b/Web/Extensions/ApiServiceExtensions.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.Options;
+
+using Web.Configuration;
+using Web.Services;
+
+namespace Web.Extensions;
+
+public static class ApiServiceExtensions
+{
+ public static void AddApiService(this IServiceCollection services)
+ {
+ services.AddScoped();
+
+ services.AddHttpClient((provider, options) =>
+ {
+ var configuration = provider.GetRequiredService>().Value;
+
+ options.BaseAddress = new Uri(configuration.BaseUrl);
+ });
+ }
+}
\ No newline at end of file
diff --git a/Web/Extensions/DoorServiceExtensions.cs b/Web/Extensions/DoorServiceExtensions.cs
new file mode 100644
index 0000000..a5dc9dd
--- /dev/null
+++ b/Web/Extensions/DoorServiceExtensions.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+
+using Web.Configuration;
+using Web.Services;
+
+namespace Web.Extensions;
+
+public static class DoorServiceExtensions
+{
+ public static void AddDoorService(this IServiceCollection services)
+ {
+ services.AddScoped();
+
+ services.TryAddTransient();
+
+ services.AddHttpClient((provider, options) =>
+ {
+ var configuration = provider.GetRequiredService>().Value;
+
+ options.BaseAddress = new Uri($"{configuration.BaseUrl}/api/doorrequest/");
+ }).AddHttpMessageHandler();
+ }
+}
+
+public class ApiAuthorizationMessageHandler : AuthorizationMessageHandler
+{
+ public ApiAuthorizationMessageHandler(
+ IAccessTokenProvider provider,
+ NavigationManager navigation,
+ IOptions configuration) : base(provider, navigation)
+ {
+ ConfigureHandler(
+ authorizedUrls: new[] { configuration.Value.BaseUrl });
+ }
+}
\ No newline at end of file
diff --git a/Web/Extensions/MudBlazorExtensions.cs b/Web/Extensions/MudBlazorExtensions.cs
new file mode 100644
index 0000000..bf03892
--- /dev/null
+++ b/Web/Extensions/MudBlazorExtensions.cs
@@ -0,0 +1,15 @@
+using MudBlazor;
+using MudBlazor.Services;
+
+namespace Web.Extensions;
+
+public static class MudBlazorExtensions
+{
+ public static IServiceCollection AddMudBlazor(this IServiceCollection services)
+ {
+ return services.AddMudServices(config =>
+ {
+ config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomCenter;
+ });
+ }
+}
diff --git a/Web/Pages/About.razor b/Web/Pages/About.razor
new file mode 100644
index 0000000..0382eff
--- /dev/null
+++ b/Web/Pages/About.razor
@@ -0,0 +1,78 @@
+@page "/about"
+@using System.Security.Claims
+@using Microsoft.AspNetCore.Components.Authorization
+@using System.Globalization
+@using global::Shared.Authorization;
+@inject AuthenticationStateProvider AuthenticationStateProvider
+
+About
+
+@authMessage
+
+
+ Current culture: @CultureInfo.CurrentUICulture
+
+
+
+
+ Logout
+
+
+
+Claims
+
+
+ @if (claims.Count() > 0)
+ {
+
+ @foreach (var claim in claims)
+ {
+ - @claim.Type: @claim.Value
+ }
+
+ }
+
+
+Roles
+
+
+ @if (roles.Count() > 0)
+ {
+
+ @foreach (var role in roles)
+ {
+ - @role
+ }
+
+ }
+ else
+ {
+ No roles found.
+ }
+
+
+@code {
+ private string? authMessage;
+ private IEnumerable claims = Enumerable.Empty();
+ private IEnumerable roles = Enumerable.Empty();
+
+ protected override async Task OnInitializedAsync()
+ {
+ var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
+ var user = authState.User;
+
+ if (user?.Identity?.IsAuthenticated ?? false)
+ {
+ authMessage = $"{user.Identity.Name} is authenticated.";
+ claims = user.Claims;
+ roles = user.Claims.Where(c => c.Type == CustomClaims.Roles).Select(c => c.Value);
+ }
+ else
+ {
+ authMessage = "The user is NOT authenticated.";
+ }
+ }
+}
\ No newline at end of file
diff --git a/Web/Pages/Authentication.razor b/Web/Pages/Authentication.razor
new file mode 100644
index 0000000..6c74356
--- /dev/null
+++ b/Web/Pages/Authentication.razor
@@ -0,0 +1,7 @@
+@page "/authentication/{action}"
+@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
+
+
+@code{
+ [Parameter] public string? Action { get; set; }
+}
diff --git a/Web/Pages/DoorControl.razor b/Web/Pages/DoorControl.razor
new file mode 100644
index 0000000..302f447
--- /dev/null
+++ b/Web/Pages/DoorControl.razor
@@ -0,0 +1,82 @@
+@page "/"
+
+@using Microsoft.AspNetCore.Authorization;
+@using Web.Services;
+@using global::Shared.Authorization;
+
+@inject IDoorService DoorService;
+@inject ISnackbar Snackbar;
+@inject IConnectionStatusService ConnectionStatusService;
+
+@attribute [Authorize(Roles = Roles.TwentyFourSevenAccess)]
+
+
+
+ @if (OpeningDoor)
+ {
+
+ Opening door...
+ }
+ else
+ {
+ Buzz me in!
+ }
+
+
+
+
+
+
+
+ Key vault code
+
+
+
+ @if (KeyVaultCode is not null)
+ {
+ @KeyVaultCode
+ }
+ else
+ {
+
+ }
+
+
+
+
+
+@code {
+ private int? KeyVaultCode { get; set; }
+
+ private bool OpeningDoor;
+
+ private bool IsOnline => ConnectionStatusService.ApiReachable;
+
+ public async Task OpenDoor()
+ {
+ OpeningDoor = true;
+ await DoorService.OpenDoor(CancellationToken.None);
+ Snackbar.Add("Door buzzer triggered successfully!", Severity.Success);
+ OpeningDoor = false;
+ }
+
+ protected override async Task OnInitializedAsync()
+ {
+ ConnectionStatusService.AddOnDisconnectedAction((ct) =>
+ {
+ Snackbar.Clear();
+ Snackbar.Add("Can not connect to API service", Severity.Error);
+ StateHasChanged();
+ return Task.CompletedTask;
+ });
+
+ ConnectionStatusService.AddOnConnectedAction((ct) =>
+ {
+ Snackbar.Clear();
+ Snackbar.Add("API Service reachable", Severity.Success);
+ StateHasChanged();
+ return Task.CompletedTask;
+ });
+ KeyVaultCode = await DoorService.GetCode(CancellationToken.None);
+ }
+}
diff --git a/Web/Program.cs b/Web/Program.cs
new file mode 100644
index 0000000..76be470
--- /dev/null
+++ b/Web/Program.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+
+using Shared.Authorization;
+
+using Web;
+using Web.Authorization;
+using Web.Extensions;
+using Web.Services;
+
+var builder = WebAssemblyHostBuilder.CreateDefault(args);
+builder.RootComponents.Add("#app");
+builder.RootComponents.Add("head::after");
+
+builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+
+builder.Services.AddOidcAuthentication(options =>
+{
+ // Configure your authentication provider options here.
+ // For more information, see https://aka.ms/blazor-standalone-auth
+ builder.Configuration.Bind("Local", options.ProviderOptions);
+ options.UserOptions.RoleClaim = CustomClaims.Roles;
+}).AddAccountClaimsPrincipalFactory();
+
+builder.Services.AddMudBlazor();
+
+builder.Services.ConfigureApiOptions(builder.Configuration);
+builder.Services.AddDoorService();
+builder.Services.AddApiService();
+builder.Services.AddSingleton();
+
+await builder.Build().RunAsync();
diff --git a/Web/Properties/launchSettings.json b/Web/Properties/launchSettings.json
new file mode 100644
index 0000000..3afd294
--- /dev/null
+++ b/Web/Properties/launchSettings.json
@@ -0,0 +1,22 @@
+{
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "dotnetRunMessages": true,
+ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+ "applicationUrl": "https://localhost:7104"
+ }
+ },
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:13524",
+ "sslPort": 44322
+ }
+ }
+}
\ No newline at end of file
diff --git a/Web/Services/ApiService.cs b/Web/Services/ApiService.cs
new file mode 100644
index 0000000..4b10e6d
--- /dev/null
+++ b/Web/Services/ApiService.cs
@@ -0,0 +1,29 @@
+namespace Web.Services;
+
+public interface IApiService
+{
+ Task IsHealthy(CancellationToken ct);
+}
+
+public class ApiService : IApiService
+{
+ private readonly HttpClient _httpClient;
+
+ public ApiService(HttpClient httpClient)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ }
+
+ public async Task IsHealthy(CancellationToken ct)
+ {
+ try
+ {
+ var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, "/healthz"), ct);
+ return response.IsSuccessStatusCode;
+ }
+ catch (HttpRequestException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/Web/Services/ConnectionStatusService.cs b/Web/Services/ConnectionStatusService.cs
new file mode 100644
index 0000000..fd30ee5
--- /dev/null
+++ b/Web/Services/ConnectionStatusService.cs
@@ -0,0 +1,59 @@
+namespace Web.Services;
+
+public class ConnectionStatusService : IConnectionStatusService
+{
+ private readonly IApiService _apiService;
+ public bool ApiReachable { get; private set; } = true;
+
+ private readonly List> _onDisconnectedActions = new();
+ private readonly List> _onConnectedActions = new();
+
+ public ConnectionStatusService(IApiService apiService)
+ {
+ _apiService = apiService ?? throw new ArgumentNullException(nameof(apiService));
+ }
+
+ public async Task StartPeriodicChecks(CancellationToken ct)
+ {
+ var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
+ while (!ct.IsCancellationRequested
+ && await timer.WaitForNextTickAsync(ct))
+ {
+ var reachable = await _apiService.IsHealthy(ct);
+ if (reachable != ApiReachable)
+ {
+ ApiReachable = reachable;
+ if (reachable)
+ await OnConnected(ct);
+ else
+ await OnDisconnected(ct);
+ }
+ }
+ }
+
+ public void AddOnDisconnectedAction(Func action)
+ {
+ _onDisconnectedActions.Add(action);
+ }
+
+ public void AddOnConnectedAction(Func action)
+ {
+ _onConnectedActions.Add(action);
+ }
+
+ private async Task OnDisconnected(CancellationToken ct)
+ {
+ foreach (var task in _onDisconnectedActions)
+ {
+ await task(ct);
+ }
+ }
+
+ private async Task OnConnected(CancellationToken ct)
+ {
+ foreach (var task in _onConnectedActions)
+ {
+ await task(ct);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Web/Services/DoorService.cs b/Web/Services/DoorService.cs
new file mode 100644
index 0000000..c017a74
--- /dev/null
+++ b/Web/Services/DoorService.cs
@@ -0,0 +1,28 @@
+using System.Net.Http.Json;
+
+namespace Web.Services;
+
+public interface IDoorService
+{
+ Task GetCode(CancellationToken ct);
+ Task OpenDoor(CancellationToken ct);
+}
+
+public class DoorService : IDoorService
+{
+ private readonly HttpClient _httpClient;
+
+ public DoorService(HttpClient httpClient)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ }
+
+ public async Task OpenDoor(CancellationToken ct)
+ {
+ var response = await _httpClient.PostAsync("open", null, ct);
+ response.EnsureSuccessStatusCode();
+ }
+
+ public Task GetCode(CancellationToken ct)
+ => _httpClient.GetFromJsonAsync("code", ct);
+}
diff --git a/Web/Services/IConnectionStatusService.cs b/Web/Services/IConnectionStatusService.cs
new file mode 100644
index 0000000..66c40a7
--- /dev/null
+++ b/Web/Services/IConnectionStatusService.cs
@@ -0,0 +1,10 @@
+namespace Web.Services;
+
+public interface IConnectionStatusService
+{
+ bool ApiReachable { get; }
+
+ void AddOnConnectedAction(Func action);
+ void AddOnDisconnectedAction(Func action);
+ Task StartPeriodicChecks(CancellationToken ct);
+}
\ No newline at end of file
diff --git a/Web/Shared/LoginDisplay.razor b/Web/Shared/LoginDisplay.razor
new file mode 100644
index 0000000..7227b00
--- /dev/null
+++ b/Web/Shared/LoginDisplay.razor
@@ -0,0 +1,40 @@
+@using Microsoft.AspNetCore.Components.Authorization
+@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
+@inject NavigationManager Navigation
+
+
+
+
+
+ Hello, @context.User.Identity?.Name!
+
+ Log out
+
+
+
+
+ Log in
+
+
+
+
+
+
+
+
+ Hello, @context.User.Identity?.Name!
+ Log out
+
+
+
+ Log in
+
+
+
+
+@code {
+ private void BeginLogOut()
+ {
+ Navigation.NavigateToLogout("authentication/logout");
+ }
+}
diff --git a/Web/Shared/MainLayout.razor b/Web/Shared/MainLayout.razor
new file mode 100644
index 0000000..47ac04c
--- /dev/null
+++ b/Web/Shared/MainLayout.razor
@@ -0,0 +1,74 @@
+@inherits LayoutComponentBase
+
+@Title
+
+
+
+
+
+
+
+
+ @Title
+
+
+
+
+
+
+ @Body
+
+
+
+@code {
+ private string Title = "DoorApp";
+ private bool _isDarkMode;
+
+ private MudThemeProvider _mudThemeProvider = default!;
+
+ [Inject]
+ private IConfiguration Configuration { get; set; } = default!;
+
+ private readonly MudTheme _currentTheme = new()
+ {
+ Palette = new PaletteLight
+ {
+ Primary = "#0A7BCF",
+ Secondary = "#4CAF50",
+ Info = "#64a7e2",
+ Success = "#2ECC40",
+ Warning = "#FFC107",
+ Error = "#FF0000",
+ AppbarBackground = "#212121",
+ TextPrimary = "#0A7BCF",
+ TextSecondary = "#4CAF50"
+ },
+ PaletteDark = new PaletteDark
+ {
+ Primary = "00BFFF",
+ Secondary = "#800080",
+ Info = "#4169E1",
+ Success = "#32CD32",
+ Warning = "#FFA500",
+ Error = "#FF0000",
+ AppbarBackground = "#121212",
+ TextPrimary = "#E0E0E0",
+ TextSecondary = "#BDBDBD"
+ }
+ };
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ Title = Configuration.GetValue("AppName") ?? "DoorApp";
+ if (firstRender)
+ {
+ _isDarkMode = await _mudThemeProvider.GetSystemPreference();
+ StateHasChanged();
+ }
+ }
+
+ void ThemeToggle() => _isDarkMode = !_isDarkMode;
+}
\ No newline at end of file
diff --git a/Web/Shared/MainLayout.razor.css b/Web/Shared/MainLayout.razor.css
new file mode 100644
index 0000000..e69de29
diff --git a/Web/Shared/RedirectToLogin.razor b/Web/Shared/RedirectToLogin.razor
new file mode 100644
index 0000000..a1cf400
--- /dev/null
+++ b/Web/Shared/RedirectToLogin.razor
@@ -0,0 +1,9 @@
+@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
+@inject NavigationManager Navigation
+
+@code {
+ protected override void OnInitialized()
+ {
+ Navigation.NavigateToLogin("authentication/login");
+ }
+}
diff --git a/Web/Shared/SurveyPrompt.razor b/Web/Shared/SurveyPrompt.razor
new file mode 100644
index 0000000..67b6b62
--- /dev/null
+++ b/Web/Shared/SurveyPrompt.razor
@@ -0,0 +1,16 @@
+
+
+
@Title
+
+
+ Please take our
+ brief survey
+
+ and tell us what you think.
+
+
+@code {
+ // Demonstrates how a parent component can supply parameters
+ [Parameter]
+ public string? Title { get; set; }
+}
diff --git a/Web/Web.csproj b/Web/Web.csproj
new file mode 100644
index 0000000..15c7969
--- /dev/null
+++ b/Web/Web.csproj
@@ -0,0 +1,45 @@
+
+
+
+ net7.0
+ enable
+ enable
+ service-worker-assets.js
+ True
+ $(MSBuildProjectName)
+
+
+
+ True
+ True
+
+
+
+ True
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Web/_Imports.razor b/Web/_Imports.razor
new file mode 100644
index 0000000..252a741
--- /dev/null
+++ b/Web/_Imports.razor
@@ -0,0 +1,12 @@
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Authorization
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.AspNetCore.Components.WebAssembly.Http
+@using Microsoft.JSInterop
+@using Web
+@using Web.Shared
+@using MudBlazor
\ No newline at end of file
diff --git a/Web/nginx.conf b/Web/nginx.conf
new file mode 100644
index 0000000..a5d350d
--- /dev/null
+++ b/Web/nginx.conf
@@ -0,0 +1,15 @@
+events { }
+http {
+ include mime.types;
+ types {
+ application/wasm;
+ }
+ server {
+ listen 80;
+ index index.html;
+ location / {
+ root /usr/share/nginx/html;
+ try_files $uri $uri/ /index.html =404;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Web/wwwroot/appsettings.Development.json b/Web/wwwroot/appsettings.Development.json
new file mode 100644
index 0000000..3fce7f4
--- /dev/null
+++ b/Web/wwwroot/appsettings.Development.json
@@ -0,0 +1,10 @@
+{
+ "Local": {
+ "Authority": "http://localhost:5302/realms/DevRealm",
+ "ClientId": "door-request-webapp",
+ "ResponseType": "code"
+ },
+ "ApiConfiguration": {
+ "BaseUrl": "https://localhost:5001"
+ }
+}
diff --git a/Web/wwwroot/appsettings.json b/Web/wwwroot/appsettings.json
new file mode 100644
index 0000000..4bbc06d
--- /dev/null
+++ b/Web/wwwroot/appsettings.json
@@ -0,0 +1,7 @@
+{
+ "Local": {
+ "Authority": "",
+ "ClientId": ""
+ },
+ "AppName": "HCKSPC DoorApp"
+}
diff --git a/Web/wwwroot/css/app.css b/Web/wwwroot/css/app.css
new file mode 100644
index 0000000..8c45091
--- /dev/null
+++ b/Web/wwwroot/css/app.css
@@ -0,0 +1,70 @@
+div#app {
+ height: 100vh;
+}
+
+div#loader {
+ background-color: rgba(50,51,61,1);
+ display: flex;
+ justify-content: center;
+ padding-top: 4rem;
+ height: 100vh;
+}
+
+.loader {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ display: inline-block;
+ position: relative;
+ border: 3px solid;
+ border-color: #FFF #FFF transparent transparent;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+ .loader::after,
+ .loader::before {
+ content: '';
+ box-sizing: border-box;
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ margin: auto;
+ border: 3px solid;
+ border-color: transparent transparent #FF3D00 #FF3D00;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ animation: rotationBack 0.5s linear infinite;
+ transform-origin: center center;
+ }
+
+ .loader::before {
+ width: 32px;
+ height: 32px;
+ border-color: #FFF #FFF transparent transparent;
+ animation: rotation 1.5s linear infinite;
+ }
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes rotationBack {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(-360deg);
+ }
+}
diff --git a/Web/wwwroot/favicon.png b/Web/wwwroot/favicon.png
new file mode 100644
index 0000000..8422b59
Binary files /dev/null and b/Web/wwwroot/favicon.png differ
diff --git a/Web/wwwroot/icon-192.png b/Web/wwwroot/icon-192.png
new file mode 100644
index 0000000..166f56d
Binary files /dev/null and b/Web/wwwroot/icon-192.png differ
diff --git a/Web/wwwroot/icon-512.png b/Web/wwwroot/icon-512.png
new file mode 100644
index 0000000..c2dd484
Binary files /dev/null and b/Web/wwwroot/icon-512.png differ
diff --git a/Web/wwwroot/index.html b/Web/wwwroot/index.html
new file mode 100644
index 0000000..999024d
--- /dev/null
+++ b/Web/wwwroot/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ Web
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
+
+
+
+
+
+
+
+
diff --git a/Web/wwwroot/manifest.json b/Web/wwwroot/manifest.json
new file mode 100644
index 0000000..a8fa3da
--- /dev/null
+++ b/Web/wwwroot/manifest.json
@@ -0,0 +1,21 @@
+{
+ "name": "Web",
+ "short_name": "Web",
+ "start_url": "./",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#03173d",
+ "prefer_related_applications": false,
+ "icons": [
+ {
+ "src": "icon-512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ },
+ {
+ "src": "icon-192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ }
+ ]
+}
diff --git a/Web/wwwroot/service-worker.js b/Web/wwwroot/service-worker.js
new file mode 100644
index 0000000..fe614da
--- /dev/null
+++ b/Web/wwwroot/service-worker.js
@@ -0,0 +1,4 @@
+// In development, always fetch from the network and do not enable offline support.
+// This is because caching would make development more difficult (changes would not
+// be reflected on the first load after each change).
+self.addEventListener('fetch', () => { });
diff --git a/Web/wwwroot/service-worker.published.js b/Web/wwwroot/service-worker.published.js
new file mode 100644
index 0000000..0d9986f
--- /dev/null
+++ b/Web/wwwroot/service-worker.published.js
@@ -0,0 +1,48 @@
+// Caution! Be sure you understand the caveats before publishing an application with
+// offline support. See https://aka.ms/blazor-offline-considerations
+
+self.importScripts('./service-worker-assets.js');
+self.addEventListener('install', event => event.waitUntil(onInstall(event)));
+self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
+self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
+
+const cacheNamePrefix = 'offline-cache-';
+const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
+const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
+const offlineAssetsExclude = [ /^service-worker\.js$/ ];
+
+async function onInstall(event) {
+ console.info('Service worker: Install');
+
+ // Fetch and cache all matching items from the assets manifest
+ const assetsRequests = self.assetsManifest.assets
+ .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
+ .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
+ .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));
+ await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
+}
+
+async function onActivate(event) {
+ console.info('Service worker: Activate');
+
+ // Delete unused caches
+ const cacheKeys = await caches.keys();
+ await Promise.all(cacheKeys
+ .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
+ .map(key => caches.delete(key)));
+}
+
+async function onFetch(event) {
+ let cachedResponse = null;
+ if (event.request.method === 'GET') {
+ // For all navigation requests, try to serve index.html from cache
+ // If you need some URLs to be server-rendered, edit the following check to exclude those URLs
+ const shouldServeIndexHtml = event.request.mode === 'navigate';
+
+ const request = shouldServeIndexHtml ? 'index.html' : event.request;
+ const cache = await caches.open(cacheName);
+ cachedResponse = await cache.match(request);
+ }
+
+ return cachedResponse || fetch(event.request);
+}
diff --git a/Web/wwwroot/web.js b/Web/wwwroot/web.js
new file mode 100644
index 0000000..8a9809a
--- /dev/null
+++ b/Web/wwwroot/web.js
@@ -0,0 +1,23 @@
+let handler;
+
+window.Connection = {
+ Initialize: function (interop) {
+
+ handler = function () {
+ interop.invokeMethodAsync("Connection.StatusChanged", navigator.onLine);
+ }
+
+ window.addEventListener("online", handler);
+ window.addEventListener("offline", handler);
+
+ handler(navigator.onLine);
+ },
+ Dispose: function () {
+
+ if (handler != null) {
+
+ window.removeEventListener("online", handler);
+ window.removeEventListener("offline", handler);
+ }
+ }
+};