├── .dockerignore ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── API ├── API.csproj ├── Controllers │ ├── AuthController.cs │ ├── StandardController.cs │ └── TelemetryController.cs ├── Env.cs ├── Extensions │ └── ServiceCollectionExtensions.cs ├── FIlters │ └── StandardResultValidationFilter.cs ├── Handlers │ ├── Identity │ │ ├── AuthHandler.cs │ │ └── RegistrationHandler.cs │ └── Telemetry │ │ └── LatestValuesHandler.cs ├── Hubs │ ├── HubSubscriber.cs │ ├── HubSubscriptions.cs │ ├── SubscriptionHub.cs │ └── TelemetryHub.cs ├── Identity │ ├── JwtSigningKey.cs │ ├── JwtTokenGenerator.cs │ └── UsersContext.cs ├── Migrations │ ├── 20230320015352_initial.Designer.cs │ ├── 20230320015352_initial.cs │ └── UsersContextModelSnapshot.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Requests │ ├── Identity │ │ ├── AuthRequest.cs │ │ ├── AuthResponse.cs │ │ └── RegistrationRequest.cs │ └── Telemetry │ │ └── LatestValuesRequest.cs ├── Shared │ ├── IStandardHandler.cs │ └── StandardResult.cs ├── appsettings.Development.json └── appsettings.json ├── Dockerfile ├── LICENSE ├── README.md ├── Registry ├── DependencyRegistry.cs ├── README.md └── Registry.csproj ├── Services ├── README.md ├── Services.csproj └── Telemetry │ ├── TelemetryGateway.cs │ └── TelemetryService.cs ├── Shared ├── Configuration │ └── IDependencyRegistry.cs ├── README.md ├── Shared.csproj └── Telemetry │ ├── ITelemetryGateway.cs │ ├── ITelemetryService.cs │ └── TelemetryData.cs ├── client ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── src │ ├── App.css │ ├── App.tsx │ ├── app │ │ ├── API.ts │ │ ├── appSlice.ts │ │ └── store.ts │ ├── assets │ │ ├── aspnet-core.png │ │ ├── react.svg │ │ └── vite.svg │ ├── components │ │ ├── banner │ │ │ ├── Banner.css │ │ │ └── Banner.tsx │ │ ├── button │ │ │ ├── Button.css │ │ │ └── Button.tsx │ │ ├── index.ts │ │ ├── input │ │ │ ├── Input.css │ │ │ └── Input.tsx │ │ ├── loader │ │ │ ├── Loader.css │ │ │ └── Loader.tsx │ │ └── toast │ │ │ ├── Toast.css │ │ │ ├── Toast.tsx │ │ │ └── ToastContainer.tsx │ ├── hooks │ │ ├── app.ts │ │ ├── index.ts │ │ ├── latestValues.ts │ │ ├── loader.ts │ │ └── telemetryHub.ts │ ├── index.css │ ├── main.tsx │ ├── pages │ │ ├── auth │ │ │ ├── Auth.tsx │ │ │ ├── Login.tsx │ │ │ └── Register.tsx │ │ ├── home │ │ │ ├── Home.tsx │ │ │ └── reducers │ │ │ │ └── counterSlice.ts │ │ ├── index.ts │ │ └── telemetry │ │ │ ├── Telemetry.tsx │ │ │ └── reducers │ │ │ ├── telemetrySlice.ts │ │ │ └── telemetryThunk.ts │ ├── types │ │ ├── app.d.ts │ │ └── telemetry.d.ts │ ├── utilities │ │ ├── datetime.ts │ │ ├── http.ts │ │ └── signalr.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── docker-compose.postgres.yml ├── docker-compose.yml └── sql └── 01-seed-identity.sql /.dockerignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | **/.classpath 3 | **/.dockerignore 4 | **/.env 5 | **/.git 6 | **/.gitignore 7 | **/.project 8 | **/.settings 9 | **/.toolstarget 10 | **/.vs 11 | **/.vscode 12 | **/*.*proj.user 13 | **/*.dbmdl 14 | **/*.jfm 15 | **/azds.yaml 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/Dockerfile* 20 | **/node_modules 21 | **/npm-debug.log 22 | **/obj 23 | **/secrets.dev.yaml 24 | **/values.dev.yaml 25 | LICENSE 26 | README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | bin/ 23 | Bin/ 24 | obj/ 25 | Obj/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | /wwwroot/dist/ 30 | 31 | # MSTest test Results 32 | [Tt]est[Rr]esult*/ 33 | [Bb]uild[Ll]og.* 34 | 35 | # NUNIT 36 | *.VisualState.xml 37 | TestResult.xml 38 | 39 | # Build Results of an ATL Project 40 | [Dd]ebugPS/ 41 | [Rr]eleasePS/ 42 | dlldata.c 43 | 44 | *_i.c 45 | *_p.c 46 | *_i.h 47 | *.ilk 48 | *.meta 49 | *.obj 50 | *.pch 51 | *.pdb 52 | *.pgc 53 | *.pgd 54 | *.rsp 55 | *.sbr 56 | *.tlb 57 | *.tli 58 | *.tlh 59 | *.tmp 60 | *.tmp_proj 61 | *.log 62 | *.vspscc 63 | *.vssscc 64 | .builds 65 | *.pidb 66 | *.svclog 67 | *.scc 68 | 69 | # Chutzpah Test files 70 | _Chutzpah* 71 | 72 | # Visual C++ cache files 73 | ipch/ 74 | *.aps 75 | *.ncb 76 | *.opendb 77 | *.opensdf 78 | *.sdf 79 | *.cachefile 80 | 81 | # Visual Studio profiler 82 | *.psess 83 | *.vsp 84 | *.vspx 85 | *.sap 86 | 87 | # TFS 2012 Local Workspace 88 | $tf/ 89 | 90 | # Guidance Automation Toolkit 91 | *.gpState 92 | 93 | # ReSharper is a .NET coding add-in 94 | _ReSharper*/ 95 | *.[Rr]e[Ss]harper 96 | *.DotSettings.user 97 | 98 | # JustCode is a .NET coding add-in 99 | .JustCode 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | _NCrunch_* 109 | .*crunch*.local.xml 110 | nCrunchTemp_* 111 | 112 | # MightyMoose 113 | *.mm.* 114 | AutoTest.Net/ 115 | 116 | # Web workbench (sass) 117 | .sass-cache/ 118 | 119 | # Installshield output folder 120 | [Ee]xpress/ 121 | 122 | # DocProject is a documentation generator add-in 123 | DocProject/buildhelp/ 124 | DocProject/Help/*.HxT 125 | DocProject/Help/*.HxC 126 | DocProject/Help/*.hhc 127 | DocProject/Help/*.hhk 128 | DocProject/Help/*.hhp 129 | DocProject/Help/Html2 130 | DocProject/Help/html 131 | 132 | # Click-Once directory 133 | publish/ 134 | 135 | # Publish Web Output 136 | *.[Pp]ublish.xml 137 | *.azurePubxml 138 | # TODO: Comment the next line if you want to checkin your web deploy settings 139 | # but database connection strings (with potential passwords) will be unencrypted 140 | *.pubxml 141 | *.publishproj 142 | 143 | # NuGet Packages 144 | *.nupkg 145 | # The packages folder can be ignored because of Package Restore 146 | **/packages/* 147 | # except build/, which is used as an MSBuild target. 148 | !**/packages/build/ 149 | # Uncomment if necessary however generally it will be regenerated when needed 150 | #!**/packages/repositories.config 151 | 152 | # Microsoft Azure Build Output 153 | csx/ 154 | *.build.csdef 155 | 156 | # Microsoft Azure Emulator 157 | ecf/ 158 | rcf/ 159 | 160 | # Microsoft Azure ApplicationInsights config file 161 | ApplicationInsights.config 162 | 163 | # Windows Store app package directory 164 | AppPackages/ 165 | BundleArtifacts/ 166 | 167 | # Visual Studio cache files 168 | # files ending in .cache can be ignored 169 | *.[Cc]ache 170 | # but keep track of directories ending in .cache 171 | !*.[Cc]ache/ 172 | 173 | # Others 174 | ClientBin/ 175 | ~$* 176 | *~ 177 | *.dbmdl 178 | *.dbproj.schemaview 179 | *.pfx 180 | *.publishsettings 181 | orleans.codegen.cs 182 | 183 | /node_modules 184 | 185 | # RIA/Silverlight projects 186 | Generated_Code/ 187 | 188 | # Backup & report files from converting an old project file 189 | # to a newer Visual Studio version. Backup files are not needed, 190 | # because we have git ;-) 191 | _UpgradeReport_Files/ 192 | Backup*/ 193 | UpgradeLog*.XML 194 | UpgradeLog*.htm 195 | 196 | # SQL Server files 197 | *.mdf 198 | *.ldf 199 | 200 | # Business Intelligence projects 201 | *.rdl.data 202 | *.bim.layout 203 | *.bim_*.settings 204 | 205 | # Microsoft Fakes 206 | FakesAssemblies/ 207 | 208 | # GhostDoc plugin setting file 209 | *.GhostDoc.xml 210 | 211 | # Node.js Tools for Visual Studio 212 | .ntvs_analysis.dat 213 | 214 | # Visual Studio 6 build log 215 | *.plg 216 | 217 | # Visual Studio 6 workspace options file 218 | *.opt 219 | 220 | # Visual Studio LightSwitch build output 221 | **/*.HTMLClient/GeneratedArtifacts 222 | **/*.DesktopClient/GeneratedArtifacts 223 | **/*.DesktopClient/ModelManifest.xml 224 | **/*.Server/GeneratedArtifacts 225 | **/*.Server/ModelManifest.xml 226 | _Pvt_Extensions 227 | 228 | # Paket dependency manager 229 | .paket/paket.exe 230 | 231 | # FAKE - F# Make 232 | .fake/ 233 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Chrome", 6 | "request": "launch", 7 | "type": "chrome", 8 | "url": "https://localhost:3000", 9 | "webRoot": "${workspaceFolder}/client" 10 | }, 11 | { 12 | "name": "Vite", 13 | "command": "npm run dev", 14 | "request": "launch", 15 | "type": "node-terminal", 16 | "cwd": "${workspaceFolder}/client", 17 | }, 18 | { 19 | "name": "API", 20 | "type": "coreclr", 21 | "request": "launch", 22 | "preLaunchTask": "build", 23 | "program": "${workspaceFolder}/api/bin/Debug/net7.0/api.dll", 24 | "args": [], 25 | "cwd": "${workspaceFolder}/api", 26 | "stopAtEntry": false, 27 | "env": { 28 | "ASPNETCORE_ENVIRONMENT": "Development", 29 | "ASPNETCORE_URLS": "https://localhost:7200", 30 | "PG_CONN_STRING": "Host=localhost;Port=7432;Database=postgres;Username=admin;Password=admin", 31 | "JWT_VALID_AUDIENCE": "cvr-audience", 32 | "JWT_VALID_ISSUER": "cvr-issuer", 33 | "JWT_SIGNING_KEY": "!*SuperSecretKey*!" 34 | }, 35 | "sourceFileMap": { 36 | "/Views": "${workspaceFolder}/Views" 37 | } 38 | }, 39 | { 40 | "name": "API Attach", 41 | "type": "coreclr", 42 | "request": "attach" 43 | } 44 | ], 45 | "compounds": [ 46 | { 47 | "name": "Vite+Chrome", 48 | "configurations": [ 49 | "Vite", 50 | "Chrome" 51 | ], 52 | "stopAll": true 53 | }, 54 | { 55 | "name": "API+Vite+Chrome", 56 | "configurations": [ 57 | "API", 58 | "Vite", 59 | "Chrome", 60 | ], 61 | "stopAll": true, 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/api/api.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/api/api.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/api/api.csproj" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /API/API.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 4bc37892-d81a-4c00-8600-9d6693f11ba7 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /API/Controllers/AuthController.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.AspNetCore.Mvc; 3 | using API.Requests.Identity; 4 | 5 | namespace API.Controllers; 6 | 7 | [ApiController] 8 | [Route("api/auth")] 9 | public class AuthController : StandardController 10 | { 11 | 12 | public AuthController(ISender sender) : base(sender) 13 | { 14 | } 15 | 16 | [HttpPost] 17 | [Route("register")] 18 | public async Task Register(RegistrationRequest request) 19 | { 20 | return await Send(request); 21 | } 22 | 23 | [HttpPost] 24 | [Route("login")] 25 | public async Task Login([FromBody] AuthRequest request) 26 | { 27 | return await Send(request); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /API/Controllers/StandardController.cs: -------------------------------------------------------------------------------- 1 | using API.Shared; 2 | using MediatR; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace API.Controllers; 6 | 7 | public abstract class StandardController : ControllerBase 8 | { 9 | private readonly ISender _sender; 10 | 11 | public StandardController(ISender sender) 12 | { 13 | _sender = sender; 14 | } 15 | 16 | protected async Task Send(IStandardRequest request) 17 | { 18 | try 19 | { 20 | var response = await _sender.Send(request); 21 | return Ok(response); 22 | } 23 | catch(UnauthorizedAccessException) 24 | { 25 | return Unauthorized(); 26 | } 27 | catch (Exception ex) 28 | { 29 | return BadRequest(new StandardResult(ex.Message)); 30 | } 31 | } 32 | 33 | protected async Task Send(IStandardRequest request) 34 | where TResponse : class 35 | { 36 | try 37 | { 38 | var response = await _sender.Send(request); 39 | return Ok(response); 40 | } 41 | catch (UnauthorizedAccessException) 42 | { 43 | return Unauthorized(); 44 | } 45 | catch (Exception ex) 46 | { 47 | return BadRequest(new StandardResult(ex.Message)); 48 | } 49 | } 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /API/Controllers/TelemetryController.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | using API.Requests.Telemetry; 5 | 6 | namespace API.Controllers; 7 | 8 | [ApiController] 9 | [Route("/api/[controller]")] 10 | public class TelemetryController : StandardController 11 | { 12 | public TelemetryController(ISender sender) : base(sender) 13 | { 14 | } 15 | 16 | [Authorize] 17 | [HttpGet(Name = "GetLatestValues")] 18 | public async Task Get() 19 | { 20 | return await Send(new LatestValuesRequest()); 21 | } 22 | } -------------------------------------------------------------------------------- /API/Env.cs: -------------------------------------------------------------------------------- 1 | 2 | public static class Env 3 | { 4 | public static string ConnectionString = Environment.GetEnvironmentVariable("PG_CONN_STRING") ?? ""; 5 | public static string JwtValidAudience = Environment.GetEnvironmentVariable("JWT_VALID_AUDIENCE") ?? ""; 6 | public static string JwtValidIssuer = Environment.GetEnvironmentVariable("JWT_VALID_ISSUER") ?? ""; 7 | public static string JwtSigningKey = Environment.GetEnvironmentVariable("JWT_SIGNING_KEY") ?? ""; 8 | } -------------------------------------------------------------------------------- /API/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Shared.Configuration; 2 | 3 | namespace API.Extensions; 4 | 5 | public static class ServiceCollectionExtensions 6 | { 7 | public static void AddDependenciesFromRegistry(this IServiceCollection services) where T: IDependencyRegistry 8 | { 9 | if (Activator.CreateInstance(typeof(T)) is IDependencyRegistry registry) 10 | { 11 | registry.Register(services); 12 | return; 13 | } 14 | 15 | throw new Exception("Unable to register dependencies. Registry not found."); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /API/FIlters/StandardResultValidationFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.Filters; 3 | using API.Shared; 4 | 5 | namespace API.FIlters; 6 | 7 | /// 8 | /// Validate ModelState and return StandardResult with errors 9 | /// 10 | public class StandardResultValidationFilter : IActionFilter 11 | { 12 | public void OnActionExecuted(ActionExecutedContext context) 13 | { 14 | 15 | } 16 | 17 | public void OnActionExecuting(ActionExecutingContext context) 18 | { 19 | if (!context.ModelState.IsValid) 20 | { 21 | var errors = context.ModelState.Values 22 | .SelectMany(e => e.Errors) 23 | .Select(x => x.ErrorMessage).ToList(); 24 | 25 | var result = new StandardResult(errors); 26 | result.Errors.AddRange(errors); 27 | context.Result = new BadRequestObjectResult(result); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /API/Handlers/Identity/AuthHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using API.Identity; 3 | using API.Requests.Identity; 4 | using API.Shared; 5 | 6 | namespace API.Handlers.Identity; 7 | 8 | public class AuthHandler : IStandardHandler 9 | { 10 | private readonly UserManager _userManager; 11 | private readonly UsersContext _context; 12 | private readonly JwtTokenGenerator _jwtTokenGenerator; 13 | 14 | public AuthHandler( 15 | UserManager userManager, 16 | UsersContext context, 17 | JwtTokenGenerator jwtTokenGenerator) 18 | { 19 | _userManager = userManager; 20 | _context = context; 21 | _jwtTokenGenerator = jwtTokenGenerator; 22 | } 23 | 24 | public async Task> Handle(AuthRequest request, CancellationToken cancellationToken) 25 | { 26 | var managedUser = await _userManager.FindByEmailAsync(request.Email); 27 | var errors = new List(); 28 | 29 | if (managedUser == null) 30 | { 31 | return new StandardResult("Invalid credentials"); 32 | } 33 | 34 | var isPasswordValid = await _userManager.CheckPasswordAsync(managedUser, request.Password); 35 | 36 | if (!isPasswordValid) 37 | { 38 | return new StandardResult("Invalid credentials"); 39 | } 40 | 41 | var user = _context.Users.FirstOrDefault(u => u.Email == request.Email); 42 | 43 | if (user is null) 44 | { 45 | throw new UnauthorizedAccessException(); 46 | } 47 | 48 | var accessToken = _jwtTokenGenerator.CreateToken(user); 49 | 50 | await _context.SaveChangesAsync(); 51 | 52 | var response = new AuthResponse() 53 | { 54 | Username = user.UserName!, 55 | Email = user.Email!, 56 | Token = accessToken, 57 | }; 58 | 59 | return new StandardResult(response); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /API/Handlers/Identity/RegistrationHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using API.Requests.Identity; 3 | using API.Shared; 4 | 5 | namespace API.Handlers.Identity; 6 | 7 | public class RegistrationHandler : IStandardHandler 8 | { 9 | private readonly UserManager _userManager; 10 | 11 | public RegistrationHandler(UserManager userManager) 12 | { 13 | _userManager = userManager; 14 | } 15 | 16 | public async Task Handle(RegistrationRequest request, CancellationToken cancellationToken) 17 | { 18 | var result = await _userManager.CreateAsync( 19 | new IdentityUser 20 | { 21 | UserName = request.UserName, 22 | Email = request.Email 23 | }, 24 | request.Password 25 | ); 26 | 27 | var response = new StandardResult(result.Succeeded); 28 | 29 | if (!result.Succeeded) 30 | { 31 | foreach (var error in result.Errors) 32 | { 33 | response.Errors.Add(error.Description); 34 | } 35 | } 36 | 37 | return response; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /API/Handlers/Telemetry/LatestValuesHandler.cs: -------------------------------------------------------------------------------- 1 | using Shared.Telemetry; 2 | using API.Requests.Telemetry; 3 | using API.Shared; 4 | 5 | namespace API.Handlers; 6 | 7 | public class LatestValuesHandler : IStandardHandler> 8 | { 9 | private readonly ITelemetryService _telemetryService; 10 | 11 | public LatestValuesHandler(ITelemetryService telemetryService) 12 | { 13 | _telemetryService = telemetryService; 14 | } 15 | 16 | public async Task>> Handle(LatestValuesRequest request, CancellationToken cancellationToken) 17 | { 18 | var latest = await _telemetryService.GetLatestValues(); 19 | 20 | return new StandardResult>(latest); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /API/Hubs/HubSubscriber.cs: -------------------------------------------------------------------------------- 1 | using API.Shared; 2 | using Microsoft.AspNetCore.SignalR; 3 | 4 | namespace API.Hubs; 5 | 6 | public class HubSubscriber : IObserver 7 | { 8 | private readonly ISingleClientProxy _client; 9 | private readonly string _method; 10 | 11 | 12 | public HubSubscriber(ISingleClientProxy client, string method) 13 | { 14 | _client = client; 15 | _method = method; 16 | } 17 | 18 | public void OnCompleted() {} 19 | 20 | public void OnError(Exception error) { } 21 | 22 | public void OnNext(T value) 23 | { 24 | try 25 | { 26 | _client.SendAsync(_method, value); 27 | } 28 | catch 29 | { 30 | // this isn't a life support monitor 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /API/Hubs/HubSubscriptions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace API.Hubs; 4 | 5 | public class HubSubscriptions 6 | { 7 | private readonly ConcurrentDictionary _subscriptions = new(); 8 | 9 | public bool Has(string id) 10 | { 11 | return _subscriptions.ContainsKey(id); 12 | } 13 | 14 | public void Add(string id, IDisposable subscription) 15 | { 16 | _subscriptions.TryAdd(id, subscription); 17 | } 18 | 19 | public void Remove(string id) 20 | { 21 | if (_subscriptions.TryRemove(id, out var subscription)) 22 | { 23 | subscription.Dispose(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /API/Hubs/SubscriptionHub.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | 3 | namespace API.Hubs; 4 | 5 | /// 6 | /// Hub with subscription semantics 7 | /// 8 | public abstract class SubscriptionHub : Hub 9 | { 10 | private static readonly HubSubscriptions _subscriptions = new(); 11 | 12 | protected abstract IDisposable OnSubscribe(ISingleClientProxy client); 13 | 14 | public override Task OnDisconnectedAsync(Exception? exception) 15 | { 16 | base.OnDisconnectedAsync(exception); 17 | return Unsubscribe(); 18 | } 19 | 20 | public Task Subscribe() 21 | { 22 | var id = Context.ConnectionId; 23 | 24 | if (!_subscriptions.Has(id)) 25 | { 26 | var client = Clients.Client(id); 27 | 28 | if (client != null) 29 | { 30 | var subscription = OnSubscribe(client); 31 | _subscriptions.Add(id, subscription); 32 | } 33 | } 34 | 35 | return Task.CompletedTask; 36 | } 37 | 38 | public Task Unsubscribe() 39 | { 40 | var id = Context.ConnectionId; 41 | 42 | _subscriptions.Remove(id); 43 | 44 | return Task.CompletedTask; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /API/Hubs/TelemetryHub.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.SignalR; 3 | using Shared.Telemetry; 4 | 5 | namespace API.Hubs; 6 | 7 | [Authorize] 8 | public class TelemetryHub : SubscriptionHub 9 | { 10 | private readonly ITelemetryGateway _gateway; 11 | 12 | public TelemetryHub(ITelemetryGateway gateway) 13 | { 14 | _gateway = gateway; 15 | } 16 | 17 | protected override IDisposable OnSubscribe(ISingleClientProxy client) 18 | { 19 | var subscriber = new HubSubscriber(client, "telemetry"); 20 | var subscription = _gateway.Subscribe(subscriber); 21 | client.SendAsync("subscribed", $"Subscribed to Telemetry Hub"); 22 | return subscription; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /API/Identity/JwtSigningKey.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace API.Identity; 4 | 5 | public class JwtSigningKey 6 | { 7 | public string ValidIssuer { get; } 8 | public string ValidAudience { get; } 9 | public byte[] Key { get; } 10 | 11 | public JwtSigningKey(string issuer, string audience, string jwtSigningKey) 12 | { 13 | ValidIssuer = issuer; 14 | ValidAudience = audience; 15 | Key = Encoding.UTF8.GetBytes(jwtSigningKey); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /API/Identity/JwtTokenGenerator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.IdentityModel.Tokens; 3 | using System.Globalization; 4 | using System.IdentityModel.Tokens.Jwt; 5 | using System.Security.Claims; 6 | 7 | namespace API.Identity; 8 | 9 | public class JwtTokenGenerator 10 | { 11 | private const int ExpirationMinutes = 30; 12 | 13 | private readonly JwtSigningKey _jwtSigningKey; 14 | 15 | public JwtTokenGenerator(JwtSigningKey jwtSigningKey) 16 | { 17 | _jwtSigningKey = jwtSigningKey; 18 | } 19 | 20 | public string CreateToken(IdentityUser user) 21 | { 22 | var expiration = DateTime.UtcNow.AddMinutes(ExpirationMinutes); 23 | 24 | var token = CreateJwtToken( 25 | CreateClaims(user), 26 | CreateSigningCredentials(), 27 | expiration 28 | ); 29 | 30 | var tokenHandler = new JwtSecurityTokenHandler(); 31 | 32 | return tokenHandler.WriteToken(token); 33 | } 34 | 35 | private JwtSecurityToken CreateJwtToken(List claims, SigningCredentials credentials, DateTime expiration) 36 | { 37 | return new( 38 | _jwtSigningKey.ValidIssuer, 39 | _jwtSigningKey.ValidAudience, 40 | claims, 41 | expires: expiration, 42 | signingCredentials: credentials 43 | ); 44 | } 45 | 46 | private static List CreateClaims(IdentityUser user) 47 | { 48 | try 49 | { 50 | var claims = new List 51 | { 52 | new Claim(JwtRegisteredClaimNames.Sub, "TokenForTheApiWithAuth"), 53 | new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), 54 | new Claim(JwtRegisteredClaimNames.Iat, DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)), 55 | new Claim(ClaimTypes.NameIdentifier, user.Id), 56 | new Claim(ClaimTypes.Name, user.UserName!), 57 | new Claim(ClaimTypes.Email, user.Email!) 58 | }; 59 | return claims; 60 | } 61 | catch (Exception e) 62 | { 63 | Console.WriteLine(e); 64 | throw; 65 | } 66 | } 67 | 68 | private SigningCredentials CreateSigningCredentials() 69 | { 70 | return new SigningCredentials( 71 | new SymmetricSecurityKey(_jwtSigningKey.Key), 72 | SecurityAlgorithms.HmacSha256 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /API/Identity/UsersContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace API.Identity; 6 | 7 | public class UsersContext : IdentityUserContext 8 | { 9 | public UsersContext(DbContextOptions options) 10 | : base(options) 11 | { 12 | } 13 | 14 | protected override void OnModelCreating(ModelBuilder modelBuilder) 15 | { 16 | base.OnModelCreating(modelBuilder); 17 | } 18 | } -------------------------------------------------------------------------------- /API/Migrations/20230320015352_initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using API.Identity; 9 | 10 | #nullable disable 11 | 12 | namespace API.Migrations 13 | { 14 | [DbContext(typeof(UsersContext))] 15 | [Migration("20230320015352_initial")] 16 | partial class Initial 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "7.0.4") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => 29 | { 30 | b.Property("Id") 31 | .HasColumnType("text"); 32 | 33 | b.Property("AccessFailedCount") 34 | .HasColumnType("integer"); 35 | 36 | b.Property("ConcurrencyStamp") 37 | .IsConcurrencyToken() 38 | .HasColumnType("text"); 39 | 40 | b.Property("Email") 41 | .HasMaxLength(256) 42 | .HasColumnType("character varying(256)"); 43 | 44 | b.Property("EmailConfirmed") 45 | .HasColumnType("boolean"); 46 | 47 | b.Property("LockoutEnabled") 48 | .HasColumnType("boolean"); 49 | 50 | b.Property("LockoutEnd") 51 | .HasColumnType("timestamp with time zone"); 52 | 53 | b.Property("NormalizedEmail") 54 | .HasMaxLength(256) 55 | .HasColumnType("character varying(256)"); 56 | 57 | b.Property("NormalizedUserName") 58 | .HasMaxLength(256) 59 | .HasColumnType("character varying(256)"); 60 | 61 | b.Property("PasswordHash") 62 | .HasColumnType("text"); 63 | 64 | b.Property("PhoneNumber") 65 | .HasColumnType("text"); 66 | 67 | b.Property("PhoneNumberConfirmed") 68 | .HasColumnType("boolean"); 69 | 70 | b.Property("SecurityStamp") 71 | .HasColumnType("text"); 72 | 73 | b.Property("TwoFactorEnabled") 74 | .HasColumnType("boolean"); 75 | 76 | b.Property("UserName") 77 | .HasMaxLength(256) 78 | .HasColumnType("character varying(256)"); 79 | 80 | b.HasKey("Id"); 81 | 82 | b.HasIndex("NormalizedEmail") 83 | .HasDatabaseName("EmailIndex"); 84 | 85 | b.HasIndex("NormalizedUserName") 86 | .IsUnique() 87 | .HasDatabaseName("UserNameIndex"); 88 | 89 | b.ToTable("AspNetUsers", (string)null); 90 | }); 91 | 92 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 93 | { 94 | b.Property("Id") 95 | .ValueGeneratedOnAdd() 96 | .HasColumnType("integer"); 97 | 98 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 99 | 100 | b.Property("ClaimType") 101 | .HasColumnType("text"); 102 | 103 | b.Property("ClaimValue") 104 | .HasColumnType("text"); 105 | 106 | b.Property("UserId") 107 | .IsRequired() 108 | .HasColumnType("text"); 109 | 110 | b.HasKey("Id"); 111 | 112 | b.HasIndex("UserId"); 113 | 114 | b.ToTable("AspNetUserClaims", (string)null); 115 | }); 116 | 117 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 118 | { 119 | b.Property("LoginProvider") 120 | .HasColumnType("text"); 121 | 122 | b.Property("ProviderKey") 123 | .HasColumnType("text"); 124 | 125 | b.Property("ProviderDisplayName") 126 | .HasColumnType("text"); 127 | 128 | b.Property("UserId") 129 | .IsRequired() 130 | .HasColumnType("text"); 131 | 132 | b.HasKey("LoginProvider", "ProviderKey"); 133 | 134 | b.HasIndex("UserId"); 135 | 136 | b.ToTable("AspNetUserLogins", (string)null); 137 | }); 138 | 139 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 140 | { 141 | b.Property("UserId") 142 | .HasColumnType("text"); 143 | 144 | b.Property("LoginProvider") 145 | .HasColumnType("text"); 146 | 147 | b.Property("Name") 148 | .HasColumnType("text"); 149 | 150 | b.Property("Value") 151 | .HasColumnType("text"); 152 | 153 | b.HasKey("UserId", "LoginProvider", "Name"); 154 | 155 | b.ToTable("AspNetUserTokens", (string)null); 156 | }); 157 | 158 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 159 | { 160 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) 161 | .WithMany() 162 | .HasForeignKey("UserId") 163 | .OnDelete(DeleteBehavior.Cascade) 164 | .IsRequired(); 165 | }); 166 | 167 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 168 | { 169 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) 170 | .WithMany() 171 | .HasForeignKey("UserId") 172 | .OnDelete(DeleteBehavior.Cascade) 173 | .IsRequired(); 174 | }); 175 | 176 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 177 | { 178 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) 179 | .WithMany() 180 | .HasForeignKey("UserId") 181 | .OnDelete(DeleteBehavior.Cascade) 182 | .IsRequired(); 183 | }); 184 | #pragma warning restore 612, 618 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /API/Migrations/20230320015352_initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 4 | 5 | #nullable disable 6 | 7 | namespace API.Migrations; 8 | 9 | /// 10 | public partial class Initial : Migration 11 | { 12 | /// 13 | protected override void Up(MigrationBuilder migrationBuilder) 14 | { 15 | migrationBuilder.CreateTable( 16 | name: "AspNetUsers", 17 | columns: table => new 18 | { 19 | Id = table.Column(type: "text", nullable: false), 20 | UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), 21 | NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), 22 | Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), 23 | NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), 24 | EmailConfirmed = table.Column(type: "boolean", nullable: false), 25 | PasswordHash = table.Column(type: "text", nullable: true), 26 | SecurityStamp = table.Column(type: "text", nullable: true), 27 | ConcurrencyStamp = table.Column(type: "text", nullable: true), 28 | PhoneNumber = table.Column(type: "text", nullable: true), 29 | PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), 30 | TwoFactorEnabled = table.Column(type: "boolean", nullable: false), 31 | LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), 32 | LockoutEnabled = table.Column(type: "boolean", nullable: false), 33 | AccessFailedCount = table.Column(type: "integer", nullable: false) 34 | }, 35 | constraints: table => 36 | { 37 | table.PrimaryKey("PK_AspNetUsers", x => x.Id); 38 | }); 39 | 40 | migrationBuilder.CreateTable( 41 | name: "AspNetUserClaims", 42 | columns: table => new 43 | { 44 | Id = table.Column(type: "integer", nullable: false) 45 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 46 | UserId = table.Column(type: "text", nullable: false), 47 | ClaimType = table.Column(type: "text", nullable: true), 48 | ClaimValue = table.Column(type: "text", nullable: true) 49 | }, 50 | constraints: table => 51 | { 52 | table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); 53 | table.ForeignKey( 54 | name: "FK_AspNetUserClaims_AspNetUsers_UserId", 55 | column: x => x.UserId, 56 | principalTable: "AspNetUsers", 57 | principalColumn: "Id", 58 | onDelete: ReferentialAction.Cascade); 59 | }); 60 | 61 | migrationBuilder.CreateTable( 62 | name: "AspNetUserLogins", 63 | columns: table => new 64 | { 65 | LoginProvider = table.Column(type: "text", nullable: false), 66 | ProviderKey = table.Column(type: "text", nullable: false), 67 | ProviderDisplayName = table.Column(type: "text", nullable: true), 68 | UserId = table.Column(type: "text", nullable: false) 69 | }, 70 | constraints: table => 71 | { 72 | table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); 73 | table.ForeignKey( 74 | name: "FK_AspNetUserLogins_AspNetUsers_UserId", 75 | column: x => x.UserId, 76 | principalTable: "AspNetUsers", 77 | principalColumn: "Id", 78 | onDelete: ReferentialAction.Cascade); 79 | }); 80 | 81 | migrationBuilder.CreateTable( 82 | name: "AspNetUserTokens", 83 | columns: table => new 84 | { 85 | UserId = table.Column(type: "text", nullable: false), 86 | LoginProvider = table.Column(type: "text", nullable: false), 87 | Name = table.Column(type: "text", nullable: false), 88 | Value = table.Column(type: "text", nullable: true) 89 | }, 90 | constraints: table => 91 | { 92 | table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); 93 | table.ForeignKey( 94 | name: "FK_AspNetUserTokens_AspNetUsers_UserId", 95 | column: x => x.UserId, 96 | principalTable: "AspNetUsers", 97 | principalColumn: "Id", 98 | onDelete: ReferentialAction.Cascade); 99 | }); 100 | 101 | migrationBuilder.CreateIndex( 102 | name: "IX_AspNetUserClaims_UserId", 103 | table: "AspNetUserClaims", 104 | column: "UserId"); 105 | 106 | migrationBuilder.CreateIndex( 107 | name: "IX_AspNetUserLogins_UserId", 108 | table: "AspNetUserLogins", 109 | column: "UserId"); 110 | 111 | migrationBuilder.CreateIndex( 112 | name: "EmailIndex", 113 | table: "AspNetUsers", 114 | column: "NormalizedEmail"); 115 | 116 | migrationBuilder.CreateIndex( 117 | name: "UserNameIndex", 118 | table: "AspNetUsers", 119 | column: "NormalizedUserName", 120 | unique: true); 121 | } 122 | 123 | /// 124 | protected override void Down(MigrationBuilder migrationBuilder) 125 | { 126 | migrationBuilder.DropTable( 127 | name: "AspNetUserClaims"); 128 | 129 | migrationBuilder.DropTable( 130 | name: "AspNetUserLogins"); 131 | 132 | migrationBuilder.DropTable( 133 | name: "AspNetUserTokens"); 134 | 135 | migrationBuilder.DropTable( 136 | name: "AspNetUsers"); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /API/Migrations/UsersContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 7 | using API.Identity; 8 | 9 | #nullable disable 10 | 11 | namespace API.Migrations 12 | { 13 | [DbContext(typeof(UsersContext))] 14 | partial class UsersContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "7.0.4") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => 26 | { 27 | b.Property("Id") 28 | .HasColumnType("text"); 29 | 30 | b.Property("AccessFailedCount") 31 | .HasColumnType("integer"); 32 | 33 | b.Property("ConcurrencyStamp") 34 | .IsConcurrencyToken() 35 | .HasColumnType("text"); 36 | 37 | b.Property("Email") 38 | .HasMaxLength(256) 39 | .HasColumnType("character varying(256)"); 40 | 41 | b.Property("EmailConfirmed") 42 | .HasColumnType("boolean"); 43 | 44 | b.Property("LockoutEnabled") 45 | .HasColumnType("boolean"); 46 | 47 | b.Property("LockoutEnd") 48 | .HasColumnType("timestamp with time zone"); 49 | 50 | b.Property("NormalizedEmail") 51 | .HasMaxLength(256) 52 | .HasColumnType("character varying(256)"); 53 | 54 | b.Property("NormalizedUserName") 55 | .HasMaxLength(256) 56 | .HasColumnType("character varying(256)"); 57 | 58 | b.Property("PasswordHash") 59 | .HasColumnType("text"); 60 | 61 | b.Property("PhoneNumber") 62 | .HasColumnType("text"); 63 | 64 | b.Property("PhoneNumberConfirmed") 65 | .HasColumnType("boolean"); 66 | 67 | b.Property("SecurityStamp") 68 | .HasColumnType("text"); 69 | 70 | b.Property("TwoFactorEnabled") 71 | .HasColumnType("boolean"); 72 | 73 | b.Property("UserName") 74 | .HasMaxLength(256) 75 | .HasColumnType("character varying(256)"); 76 | 77 | b.HasKey("Id"); 78 | 79 | b.HasIndex("NormalizedEmail") 80 | .HasDatabaseName("EmailIndex"); 81 | 82 | b.HasIndex("NormalizedUserName") 83 | .IsUnique() 84 | .HasDatabaseName("UserNameIndex"); 85 | 86 | b.ToTable("AspNetUsers", (string)null); 87 | }); 88 | 89 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 90 | { 91 | b.Property("Id") 92 | .ValueGeneratedOnAdd() 93 | .HasColumnType("integer"); 94 | 95 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 96 | 97 | b.Property("ClaimType") 98 | .HasColumnType("text"); 99 | 100 | b.Property("ClaimValue") 101 | .HasColumnType("text"); 102 | 103 | b.Property("UserId") 104 | .IsRequired() 105 | .HasColumnType("text"); 106 | 107 | b.HasKey("Id"); 108 | 109 | b.HasIndex("UserId"); 110 | 111 | b.ToTable("AspNetUserClaims", (string)null); 112 | }); 113 | 114 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 115 | { 116 | b.Property("LoginProvider") 117 | .HasColumnType("text"); 118 | 119 | b.Property("ProviderKey") 120 | .HasColumnType("text"); 121 | 122 | b.Property("ProviderDisplayName") 123 | .HasColumnType("text"); 124 | 125 | b.Property("UserId") 126 | .IsRequired() 127 | .HasColumnType("text"); 128 | 129 | b.HasKey("LoginProvider", "ProviderKey"); 130 | 131 | b.HasIndex("UserId"); 132 | 133 | b.ToTable("AspNetUserLogins", (string)null); 134 | }); 135 | 136 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 137 | { 138 | b.Property("UserId") 139 | .HasColumnType("text"); 140 | 141 | b.Property("LoginProvider") 142 | .HasColumnType("text"); 143 | 144 | b.Property("Name") 145 | .HasColumnType("text"); 146 | 147 | b.Property("Value") 148 | .HasColumnType("text"); 149 | 150 | b.HasKey("UserId", "LoginProvider", "Name"); 151 | 152 | b.ToTable("AspNetUserTokens", (string)null); 153 | }); 154 | 155 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 156 | { 157 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) 158 | .WithMany() 159 | .HasForeignKey("UserId") 160 | .OnDelete(DeleteBehavior.Cascade) 161 | .IsRequired(); 162 | }); 163 | 164 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 165 | { 166 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) 167 | .WithMany() 168 | .HasForeignKey("UserId") 169 | .OnDelete(DeleteBehavior.Cascade) 170 | .IsRequired(); 171 | }); 172 | 173 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 174 | { 175 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) 176 | .WithMany() 177 | .HasForeignKey("UserId") 178 | .OnDelete(DeleteBehavior.Cascade) 179 | .IsRequired(); 180 | }); 181 | #pragma warning restore 612, 618 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /API/Program.cs: -------------------------------------------------------------------------------- 1 | using API.Extensions; 2 | using API.FIlters; 3 | using API.Hubs; 4 | using API.Identity; 5 | using Microsoft.AspNetCore.Authentication.JwtBearer; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.EntityFrameworkCore; 8 | using Microsoft.IdentityModel.Tokens; 9 | using Microsoft.OpenApi.Models; 10 | using Registry; 11 | 12 | var builder = WebApplication.CreateBuilder(args); 13 | 14 | var jwtSigningKey = new JwtSigningKey( 15 | Env.JwtValidIssuer, 16 | Env.JwtValidAudience, 17 | Env.JwtSigningKey 18 | ); 19 | 20 | builder.Services.AddDbContext(options => { 21 | options.UseNpgsql(Environment.GetEnvironmentVariable("PG_CONN_STRING")); 22 | }); 23 | 24 | builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 25 | .AddJwtBearer(options => 26 | { 27 | options.TokenValidationParameters = new TokenValidationParameters() 28 | { 29 | ClockSkew = TimeSpan.Zero, 30 | ValidateIssuer = true, 31 | ValidateAudience = true, 32 | ValidateLifetime = true, 33 | ValidateIssuerSigningKey = true, 34 | ValidIssuer = jwtSigningKey.ValidIssuer, 35 | ValidAudience = jwtSigningKey.ValidAudience, 36 | IssuerSigningKey = new SymmetricSecurityKey(jwtSigningKey.Key) 37 | }; 38 | 39 | options.Events = new JwtBearerEvents 40 | { 41 | OnMessageReceived = context => 42 | { 43 | var accessToken = context.Request.Query["access_token"]; 44 | 45 | var path = context.HttpContext.Request.Path; 46 | if (!string.IsNullOrEmpty(accessToken) && 47 | path.StartsWithSegments("/signalr")) 48 | { 49 | context.Token = accessToken; 50 | } 51 | 52 | return Task.CompletedTask; 53 | } 54 | }; 55 | }); 56 | 57 | builder.Services 58 | .AddIdentityCore(options => 59 | { 60 | options.SignIn.RequireConfirmedAccount = false; 61 | options.User.RequireUniqueEmail = true; 62 | options.Password.RequireDigit = true; 63 | options.Password.RequiredLength = 8; 64 | options.Password.RequireNonAlphanumeric = true; 65 | options.Password.RequireUppercase = true; 66 | options.Password.RequireLowercase = true; 67 | }) 68 | .AddEntityFrameworkStores(); 69 | 70 | builder.Services.AddSignalR(hubOptions => 71 | { 72 | hubOptions.KeepAliveInterval = TimeSpan.FromSeconds(15); 73 | hubOptions.HandshakeTimeout = TimeSpan.FromSeconds(15); 74 | hubOptions.EnableDetailedErrors = true; 75 | }); 76 | 77 | builder.Services.AddControllers(config => 78 | { 79 | // validate model state and return errors in a standard API response 80 | config.Filters.Add(new StandardResultValidationFilter()); 81 | }) 82 | .ConfigureApiBehaviorOptions(options => 83 | { 84 | options.SuppressModelStateInvalidFilter = true; 85 | }); 86 | 87 | builder.Services.AddEndpointsApiExplorer(); 88 | 89 | builder.Services.AddSwaggerGen(option => 90 | { 91 | option.SwaggerDoc("v1", new OpenApiInfo { Title = "Core-Vite-React", Version = "v1" }); 92 | 93 | option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme 94 | { 95 | In = ParameterLocation.Header, 96 | Description = "Please enter a valid token", 97 | Name = "Authorization", 98 | Type = SecuritySchemeType.Http, 99 | BearerFormat = "JWT", 100 | Scheme = "Bearer" 101 | }); 102 | 103 | option.AddSecurityRequirement(new OpenApiSecurityRequirement 104 | { 105 | { 106 | new OpenApiSecurityScheme 107 | { 108 | Reference = new OpenApiReference 109 | { 110 | Type=ReferenceType.SecurityScheme, 111 | Id="Bearer" 112 | } 113 | }, 114 | new string[]{} 115 | } 116 | }); 117 | }); 118 | 119 | builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); 120 | 121 | // configure dependency inject using our registry 122 | builder.Services.AddDependenciesFromRegistry(); 123 | // add a few local dependencies 124 | builder.Services.AddSingleton((_) => { 125 | return new JwtSigningKey( 126 | Env.JwtValidIssuer, 127 | Env.JwtValidAudience, 128 | Env.JwtSigningKey 129 | ); 130 | }); 131 | builder.Services.AddScoped(); 132 | 133 | var app = builder.Build(); 134 | 135 | if (app.Environment.IsDevelopment()) 136 | { 137 | app.UseSwagger(); 138 | app.UseSwaggerUI(); 139 | } 140 | else 141 | { 142 | app.UseHsts(); 143 | } 144 | 145 | app.UseHttpsRedirection(); 146 | app.UseStaticFiles(); 147 | 148 | app.UseAuthentication(); 149 | app.UseAuthorization(); 150 | 151 | app.MapHub("/signalr/telemetry"); 152 | 153 | app.MapControllers(); 154 | app.MapFallbackToFile("index.html"); 155 | 156 | app.Run(); 157 | -------------------------------------------------------------------------------- /API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:49459", 7 | "sslPort": 44313 8 | } 9 | }, 10 | "profiles": { 11 | "http": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "http://localhost:5058", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "https": { 21 | "commandName": "Project", 22 | "dotnetRunMessages": true, 23 | "launchBrowser": true, 24 | "applicationUrl": "https://localhost:7150;http://localhost:5058", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | }, 29 | "IIS Express": { 30 | "commandName": "IISExpress", 31 | "launchBrowser": true, 32 | "environmentVariables": { 33 | "ASPNETCORE_ENVIRONMENT": "Development" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /API/Requests/Identity/AuthRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using API.Shared; 3 | 4 | namespace API.Requests.Identity; 5 | 6 | public class AuthRequest : IStandardRequest 7 | { 8 | [Required] 9 | public required string Email { get; init; } 10 | [Required] 11 | public required string Password { get; init; } 12 | } 13 | -------------------------------------------------------------------------------- /API/Requests/Identity/AuthResponse.cs: -------------------------------------------------------------------------------- 1 | namespace API.Requests.Identity; 2 | 3 | public record AuthResponse 4 | { 5 | public required string Username { get; init; } 6 | public required string Email { get; init; } 7 | public required string Token { get; init; } 8 | } 9 | -------------------------------------------------------------------------------- /API/Requests/Identity/RegistrationRequest.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using System.ComponentModel.DataAnnotations; 3 | using API.Shared; 4 | 5 | namespace API.Requests.Identity; 6 | 7 | public class RegistrationRequest : IStandardRequest 8 | { 9 | [Required] 10 | public string Email { get; set; } = null!; 11 | [Required] 12 | public string UserName { get; set; } = null!; 13 | [Required] 14 | public string Password { get; set; } = null!; 15 | } 16 | 17 | -------------------------------------------------------------------------------- /API/Requests/Telemetry/LatestValuesRequest.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Shared.Telemetry; 3 | using API.Shared; 4 | 5 | namespace API.Requests.Telemetry; 6 | 7 | public class LatestValuesRequest : IStandardRequest> 8 | { 9 | } 10 | -------------------------------------------------------------------------------- /API/Shared/IStandardHandler.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace API.Shared; 4 | 5 | public interface IStandardRequest : IRequest 6 | { 7 | 8 | } 9 | 10 | public interface IStandardHandler : IRequestHandler 11 | where T : class, IRequest 12 | { 13 | 14 | } 15 | 16 | 17 | public interface IStandardRequest : IRequest> 18 | where T: class 19 | { 20 | 21 | } 22 | 23 | public interface IStandardHandler : IRequestHandler> 24 | where T : class, IRequest> 25 | where R : class 26 | { 27 | 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /API/Shared/StandardResult.cs: -------------------------------------------------------------------------------- 1 | namespace API.Shared; 2 | 3 | public class Void { 4 | 5 | } 6 | 7 | public class StandardResult where T : class 8 | { 9 | public bool Success { get; set; } 10 | public List Errors { get; set; } = new(); 11 | public T? Result { get; set; } 12 | 13 | public StandardResult(bool success) { 14 | Success = success; 15 | } 16 | 17 | public StandardResult(T result) { 18 | Result = result; 19 | Success = true; 20 | } 21 | 22 | public StandardResult(IList errors) { 23 | Errors.AddRange(errors); 24 | Success = false; 25 | } 26 | 27 | public StandardResult(string error) { 28 | Errors.Add(error); 29 | Success = false; 30 | } 31 | } 32 | 33 | public class StandardResult : StandardResult { 34 | 35 | public StandardResult(bool success): base(success) { } 36 | public StandardResult(IList errors): base(errors) { } 37 | public StandardResult(string error): base(error) { } 38 | } -------------------------------------------------------------------------------- /API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "Jwt": { 9 | "ValidIssuer": "core-vite-react-issuer", 10 | "ValidAudience": "core-vite-react-audience" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /API/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # API and client served from ASP.NET Core 2 | FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base 3 | WORKDIR /app 4 | 5 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-node 6 | RUN bash -E $(curl -fsSL https://deb.nodesource.com/setup_19.x | bash - ); apt install -y nodejs 7 | 8 | FROM build-node AS build 9 | WORKDIR /src 10 | COPY ["API/API.csproj", "API/"] 11 | COPY ["Registry/Registry.csproj", "Registry/"] 12 | COPY ["Services/Services.csproj", "Services/"] 13 | COPY ["Shared/Shared.csproj", "Shared/"] 14 | RUN dotnet restore "API/API.csproj" 15 | COPY . . 16 | WORKDIR "/src/API" 17 | RUN dotnet build "API.csproj" -c Release -o /app/build 18 | 19 | FROM build AS publish 20 | RUN dotnet publish "API.csproj" -c Release -o /app/publish /p:UseAppHost=false 21 | 22 | FROM build-node as frontend 23 | WORKDIR /src 24 | COPY client . 25 | RUN npm ci && npm run build 26 | 27 | FROM base AS final 28 | WORKDIR /app 29 | COPY --from=publish /app/publish . 30 | COPY --from=frontend /src/dist wwwroot 31 | EXPOSE 443 32 | 33 | # within network, host is service name and internal port is used 34 | ENV PG_CONN_STRING="Host=cvr-db;Port=5432;Database=postgres;Username=admin;Password=admin" 35 | ENV JWT_VALID_AUDIENCE="cvr-audience" 36 | ENV JWT_VALID_ISSUER="cvr-issuer" 37 | ENV JWT_SIGNING_KEY="!*SuperSecretKey*!" 38 | 39 | ENTRYPOINT ["dotnet", "API.dll"] 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 J.P. Hamilton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASP.NET Core + Vite + React 2 | 3 | Not a starter, just an exploration into hooking these things up, playing with features, syntax and semantics. 4 | 5 | Includes: 6 | - ASP.NET Core v7 (JWT Authentication) 7 | - React v18 (Redux, React Router) 8 | - Vite (React, TypeScript + SWC template) 9 | - SignalR (with secure web sockets) 10 | 11 | 12 | ## Notes just for me 13 | 14 | Vite runs on port 3000 and proxies api and web sockets calls to the API on port 7200 15 | 16 | ### Trusted development certificates 17 | See [Developing ASP.NET Core Applications with Docker over HTTPS](https://github.com/dotnet/dotnet-docker/blob/main/samples/run-aspnetcore-https-development.md) 18 | ``` 19 | // Windows 20 | dotnet dev-certs https 21 | 22 | dotnet dev-certs https --trust 23 | ``` 24 | 25 | ### Docker 26 | This will spin up a container and seed the database with the required tables 27 | for identity management. 28 | ``` 29 | docker compose -f docker-compose.postgres.yml up -d 30 | ``` 31 | 32 | pgAdmin will be available at http://localhost:7433. To add a new server, Host must match the service name. In this case, cvr-postgres. However, port should be the internal port 5432 33 | 34 | To just run the whole thing from a container (in development mode) 35 | ``` 36 | docker compose up -d 37 | ``` 38 | -------------------------------------------------------------------------------- /Registry/DependencyRegistry.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Services.Telemetry; 3 | using Shared.Configuration; 4 | using Shared.Telemetry; 5 | 6 | namespace Registry; 7 | 8 | public class DependencyRegistry : IDependencyRegistry 9 | { 10 | public void Register(IServiceCollection services) 11 | { 12 | services.AddSingleton(); 13 | services.AddSingleton(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Registry/README.md: -------------------------------------------------------------------------------- 1 | # Registry 2 | 3 | The idea here is couple all of the dependencies in the application into one place for 4 | the sole purpose of configuring dependency injection. -------------------------------------------------------------------------------- /Registry/Registry.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | net7.0 10 | enable 11 | enable 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Services/README.md: -------------------------------------------------------------------------------- 1 | # Services 2 | 3 | Application services -------------------------------------------------------------------------------- /Services/Services.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | net7.0 9 | enable 10 | enable 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Services/Telemetry/TelemetryGateway.cs: -------------------------------------------------------------------------------- 1 | using Shared.Telemetry; 2 | 3 | namespace Services.Telemetry; 4 | 5 | // In real life, imagine this is a MassTransit Consumer connected to 6 | // RabbitMQ or something similar. With realtime data flowing in... 7 | public class TelemetryGateway : ITelemetryGateway 8 | { 9 | private readonly List> observers = new(); 10 | private readonly ITelemetryService telemetryService; 11 | 12 | public TelemetryGateway(ITelemetryService telemetryService) 13 | { 14 | var timer = new Timer(OnTimerAsync, null, 5000, 5000); 15 | this.telemetryService = telemetryService; 16 | } 17 | 18 | private async void OnTimerAsync(object? state) 19 | { 20 | if (observers.Any()) 21 | { 22 | var data = await telemetryService.GetLatestValues(); 23 | 24 | foreach (var observer in observers) 25 | { 26 | observer.OnNext(data.ToArray()); 27 | } 28 | } 29 | } 30 | 31 | public IDisposable Subscribe(IObserver observer) 32 | { 33 | if (!observers.Contains(observer)) 34 | { 35 | observers.Add(observer); 36 | } 37 | 38 | return new Unsubscriber(observers, observer); 39 | } 40 | 41 | private class Unsubscriber : IDisposable 42 | { 43 | private readonly List> _observers; 44 | private readonly IObserver _observer; 45 | 46 | public Unsubscriber(List> observers, IObserver observer) 47 | { 48 | _observers = observers; 49 | _observer = observer; 50 | } 51 | 52 | public void Dispose() 53 | { 54 | if (!(_observer == null)) 55 | { 56 | _observers.Remove(_observer); 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Services/Telemetry/TelemetryService.cs: -------------------------------------------------------------------------------- 1 | using Shared.Telemetry; 2 | 3 | namespace Services.Telemetry; 4 | 5 | // Generate some fake data 6 | 7 | public class TelemetryService : ITelemetryService 8 | { 9 | private class Tag 10 | { 11 | public string? Name { get; set;} 12 | public string? Unit { get; set;} 13 | } 14 | 15 | private static readonly List Tags = new() 16 | { 17 | new Tag { Name = "Temperature", Unit = "F" }, 18 | new Tag { Name = "Pressure", Unit = "psi" }, 19 | new Tag { Name = "Current", Unit = "A" }, 20 | new Tag { Name = "Voltage", Unit = "V" }, 21 | new Tag { Name = "Frequency", Unit = "Hz" }, 22 | }; 23 | 24 | public Task> GetLatestValues() 25 | { 26 | var result = Tags.Select(t => new TelemetryData 27 | { 28 | Timestamp = DateTime.UtcNow.AddSeconds(Random.Shared.Next(-10, 0)), 29 | Sensor = t.Name, 30 | Value = Math.Round(Random.Shared.NextDouble() * Random.Shared.Next(100, 200), 2), 31 | Unit = t.Unit 32 | }); 33 | 34 | return Task.FromResult(result); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Shared/Configuration/IDependencyRegistry.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Shared.Configuration; 4 | 5 | public interface IDependencyRegistry 6 | { 7 | void Register(IServiceCollection serviceCollection); 8 | } 9 | -------------------------------------------------------------------------------- /Shared/README.md: -------------------------------------------------------------------------------- 1 | # Shared 2 | Shared types and interfaces. Common types used by all layers of the application. -------------------------------------------------------------------------------- /Shared/Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Shared/Telemetry/ITelemetryGateway.cs: -------------------------------------------------------------------------------- 1 | namespace Shared.Telemetry; 2 | 3 | public interface ITelemetryGateway : IObservable 4 | { 5 | 6 | } -------------------------------------------------------------------------------- /Shared/Telemetry/ITelemetryService.cs: -------------------------------------------------------------------------------- 1 | namespace Shared.Telemetry; 2 | 3 | public interface ITelemetryService 4 | { 5 | Task> GetLatestValues(); 6 | } 7 | -------------------------------------------------------------------------------- /Shared/Telemetry/TelemetryData.cs: -------------------------------------------------------------------------------- 1 | namespace Shared.Telemetry; 2 | 3 | public class TelemetryData 4 | { 5 | public DateTime Timestamp { get; set; } = DateTime.UtcNow; 6 | public string? Sensor { get; set; } 7 | public double Value { get; set; } 8 | public string? Unit { get; set; } 9 | } -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tsc:watch": "tsc-watch --noClear" 11 | }, 12 | "dependencies": { 13 | "@microsoft/signalr": "^7.0.4", 14 | "@reduxjs/toolkit": "^1.9.3", 15 | "axios": "^1.3.4", 16 | "classnames": "^2.3.2", 17 | "dayjs": "^1.11.7", 18 | "postcss": "^8.4.21", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-redux": "^8.0.5", 22 | "react-router-dom": "^6.9.0", 23 | "uuid": "^9.0.0" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^18.15.3", 27 | "@types/react": "^18.0.28", 28 | "@types/react-dom": "^18.0.11", 29 | "@types/uuid": "^9.0.1", 30 | "@vitejs/plugin-react": "^3.1.0", 31 | "@vitejs/plugin-react-swc": "^3.2.0", 32 | "autoprefixer": "^10.4.14", 33 | "browserslist-to-esbuild": "^1.2.0", 34 | "tsc-watch": "^6.0.0", 35 | "typescript": "^5.0.2", 36 | "vite": "^4.2.0", 37 | "vite-plugin-mkcert": "^1.13.3" 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | "last 2 versions", 42 | ">0.2%", 43 | "not dead" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer')({}) 4 | ], 5 | }; -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | height: 100vh; 3 | display: flex; 4 | flex-direction: column; 5 | flex: 1; 6 | } 7 | 8 | .active { 9 | color: var(--color-active); 10 | } 11 | 12 | .app-container { 13 | display: flex; 14 | flex-direction: column; 15 | flex: 1; 16 | } 17 | 18 | .page-container { 19 | margin: 0 auto; 20 | padding: 2rem; 21 | text-align: center; 22 | display: flex; 23 | flex-direction: column; 24 | } 25 | 26 | .form-container { 27 | } 28 | 29 | .form-container > * { 30 | width: 100%; 31 | margin-top: 0.5em; 32 | } 33 | 34 | .row { 35 | display: flex; 36 | flex-direction: row; 37 | } 38 | 39 | .menu { 40 | display: flex; 41 | flex-direction: row; 42 | justify-content: center; 43 | align-items: center; 44 | } 45 | 46 | .menu > * { 47 | margin: 0 0.5em; 48 | } 49 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, NavLink, Routes, Route, Outlet } from 'react-router-dom'; 2 | import { selectLoading, selectToasts, selectUser } from '@/app/appSlice'; 3 | import { useAppSelector } from '@/hooks'; 4 | import { Banner, Loader, ToastContainer } from '@/components'; 5 | import { Auth, Home, Telemetry } from '@/pages'; 6 | 7 | import './App.css' 8 | 9 | function App() { 10 | const isLoading = useAppSelector(selectLoading); 11 | const toasts = useAppSelector(selectToasts); 12 | const user = useAppSelector(selectUser); 13 | 14 | return ( 15 |
16 | 17 | 19 | 20 |
21 | Home 22 | Telemetry 23 | { 24 | !user.token && Login 25 | } 26 |
27 |
28 | 29 |
30 |
} 31 | > 32 | } /> 33 | } /> 34 | } /> 35 | } /> 36 | 37 | 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | export default App; 45 | 46 | -------------------------------------------------------------------------------- /client/src/app/API.ts: -------------------------------------------------------------------------------- 1 | import { get, post } from '@/utilities/http'; 2 | 3 | export const latestValues = async () => { 4 | return new Promise>(resolve => { 5 | // simulate delay to test loader 6 | setTimeout(async () => { 7 | const response = await get('/api/telemetry'); 8 | resolve(response); 9 | }, 1000); 10 | }); 11 | } 12 | 13 | export const register = async (email: string, userName: string, password: string): StandardPromise => { 14 | return await post('/api/auth/register', { email, userName, password}); 15 | } 16 | 17 | export const login = async (email: string, password: string): Promise> => { 18 | return await post('/api/auth/login', { email, password}); 19 | } -------------------------------------------------------------------------------- /client/src/app/appSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import type { RootState } from '@/app/store'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | type AppState = { 6 | userName: string; 7 | email: string; 8 | token: string | null; 9 | registered: boolean; 10 | errors: string[]; 11 | loading: boolean; 12 | toasts: Toast[]; 13 | }; 14 | 15 | const initialState: AppState = { 16 | registered: false, 17 | userName: '', 18 | email: '', 19 | token: null, 20 | errors: [], 21 | loading: false, 22 | toasts: [] 23 | }; 24 | 25 | const appSlice = createSlice({ 26 | name: 'app', 27 | initialState, 28 | reducers: { 29 | loggedIn: (state, action: PayloadAction) => { 30 | const { email, userName, token } = action.payload; 31 | state.email = email; 32 | state.userName = userName; 33 | state.token = token; 34 | }, 35 | registered: (state, action: PayloadAction) => { 36 | state.registered = action.payload; 37 | }, 38 | errors: (state, action: PayloadAction) => { 39 | state.errors = action.payload; 40 | }, 41 | loading: (state, action: PayloadAction) => { 42 | state.loading = action.payload; 43 | }, 44 | toast: (state, action: PayloadAction) => { 45 | const toast = action.payload; 46 | toast.id = uuidv4(); 47 | state.toasts.push(action.payload); 48 | }, 49 | closeToast: (state, action: PayloadAction) => { 50 | state.toasts = state.toasts.filter(x => x.id !== action.payload); 51 | } 52 | } 53 | }); 54 | 55 | const toast = createAction('app/toast', (message: string, type: ToastType ) => { 56 | return { 57 | payload: { 58 | message, 59 | type 60 | } 61 | } 62 | }); 63 | 64 | export { toast }; 65 | 66 | export const { errors, loading, loggedIn, registered } = appSlice.actions; 67 | 68 | export const selectUser = (state: RootState) => ({ 69 | email: state.app.email, 70 | userName: state.app.userName, 71 | token: state.app.token 72 | }); 73 | 74 | export const selectRegistered = (state: RootState) => state.app.registered; 75 | export const selectLoading = (state: RootState) => state.app.loading; 76 | export const selectErrors = (state: RootState) => state.app.errors; 77 | export const selectToasts = (state: RootState) => state.app.toasts; 78 | 79 | export default appSlice.reducer; -------------------------------------------------------------------------------- /client/src/app/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, AnyAction, ThunkAction } from '@reduxjs/toolkit'; 2 | import appReducer from './appSlice'; 3 | import counterReducer from '@/pages/home/reducers/counterSlice'; 4 | import telemetryReducer from '@/pages/telemetry/reducers/telemetrySlice'; 5 | 6 | const store = configureStore({ 7 | reducer: { 8 | app: appReducer, 9 | counter: counterReducer, 10 | telemetry: telemetryReducer 11 | } 12 | }); 13 | 14 | export type RootState = ReturnType; 15 | 16 | export type AppDispatch = typeof store.dispatch; 17 | 18 | export type AppThunk = ThunkAction; 19 | 20 | export default store; -------------------------------------------------------------------------------- /client/src/assets/aspnet-core.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jphamilton/core-vite-react/4fb25b1ffe55bc2779a979d279682eb0132324ef/client/src/assets/aspnet-core.png -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/components/banner/Banner.css: -------------------------------------------------------------------------------- 1 | .banner { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | } 6 | 7 | .logo { 8 | height: 6em; 9 | padding: 1.5em; 10 | will-change: filter; 11 | transition: filter 300ms; 12 | } 13 | 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | 18 | .logo.react:hover { 19 | filter: drop-shadow(0 0 2em #61dafbaa); 20 | } 21 | 22 | @keyframes logo-spin { 23 | from { 24 | transform: rotate(0deg); 25 | } 26 | 27 | to { 28 | transform: rotate(360deg); 29 | } 30 | } 31 | 32 | @media (prefers-reduced-motion: no-preference) { 33 | a:nth-of-type(3) .logo { 34 | animation: logo-spin infinite 20s linear; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/src/components/banner/Banner.tsx: -------------------------------------------------------------------------------- 1 | import aspCoreLogo from '@/assets/aspnet-core.png'; 2 | import reactLogo from '@/assets/react.svg'; 3 | import viteLogo from '@/assets/vite.svg'; 4 | 5 | import './Banner.css'; 6 | 7 | export const Banner = () => { 8 | return ( 9 |
10 | 21 | 22 |

ASP.NET Core + Vite + React

23 |
24 | ); 25 | } -------------------------------------------------------------------------------- /client/src/components/button/Button.css: -------------------------------------------------------------------------------- 1 | button { 2 | border-radius: 4px; 3 | border: 1px solid transparent; 4 | padding: 0.6em 1.2em; 5 | font-size: 1em; 6 | font-weight: 500; 7 | font-family: inherit; 8 | background-color: var(--color-input-background); 9 | cursor: pointer; 10 | transition: border-color 0.25s; 11 | } 12 | 13 | button:hover { 14 | border-color: var(--color-accent); 15 | } 16 | 17 | @media (prefers-color-scheme: light) { 18 | button { 19 | background-color: #f9f9f9; 20 | } 21 | } 22 | 23 | button.submit { 24 | background-color: var(--color-accent); 25 | } 26 | 27 | button.submit:hover { 28 | background-color: var(--color-hover); 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, PropsWithChildren } from 'react'; 2 | 3 | import './Button.css'; 4 | 5 | type ButtonProps = PropsWithChildren<{ 6 | onClick: () => void; 7 | className?: string; 8 | disabled?: boolean; 9 | style?: CSSProperties; 10 | }> 11 | 12 | export const Button = (props: ButtonProps) => { 13 | const { className, disabled, onClick, children, style } = props; 14 | return ( 15 | 22 | ) 23 | } -------------------------------------------------------------------------------- /client/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Banner } from './banner/Banner'; 2 | export { Button } from './button/Button'; 3 | export { Input } from './input/Input'; 4 | export { Loader } from './loader/Loader'; 5 | export { Toast } from './toast/Toast'; 6 | export { ToastContainer } from './toast/ToastContainer'; 7 | -------------------------------------------------------------------------------- /client/src/components/input/Input.css: -------------------------------------------------------------------------------- 1 | input { 2 | background-color: var(--color-input-background); 3 | border: 1px solid transparent; 4 | border-radius: 4px; 5 | font-size: 1em; 6 | height: 42px; 7 | padding-left: 0.5em; 8 | } 9 | 10 | input:hover { 11 | border: 1px solid var(--color-accent); 12 | } 13 | 14 | input:focus { 15 | border: 1px solid var(--color-accent); 16 | outline: none; 17 | } 18 | 19 | 20 | -------------------------------------------------------------------------------- /client/src/components/input/Input.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useState } from 'react'; 2 | 3 | import './Input.css'; 4 | 5 | interface InputProps { 6 | type?: string; 7 | value?: string; 8 | placeholder?: string; 9 | onChange: (value: string) => void 10 | /** 11 | * If true, updates store on every keystroke. Otherwise waits until onblur 12 | */ 13 | immediate?: boolean; 14 | }; 15 | 16 | export const Input = (props: InputProps) => { 17 | const { immediate, placeholder, type } = props; 18 | const [value, setValue] = useState(props.value || ''); 19 | 20 | const onChange = (e: ChangeEvent) => { 21 | if (immediate) { 22 | props.onChange(e.target.value); 23 | } 24 | setValue(e.target.value); 25 | }; 26 | 27 | const onBlur = () => { 28 | if (!!value.length && value !== props.value && !immediate) { 29 | props.onChange(value); 30 | } 31 | } 32 | 33 | return ( 34 | 41 | ); 42 | } -------------------------------------------------------------------------------- /client/src/components/loader/Loader.css: -------------------------------------------------------------------------------- 1 | .loader-overlay { 2 | left: 0; 3 | top: 0; 4 | width: 100%; 5 | height: 100%; 6 | position: fixed; 7 | background-color: rgba(100, 108, 255, 0.4); 8 | z-index: 50; 9 | } 10 | 11 | .loader-overlay--content { 12 | position: absolute; 13 | left: 50%; 14 | top: 50%; 15 | transform: translate(-50%, -50%); 16 | } 17 | 18 | .loader { 19 | width: 125px; 20 | padding: 4px; 21 | aspect-ratio: 1; 22 | border-radius: 50%; 23 | background: var(--color-accent); 24 | --_m: conic-gradient(#0000 10%,#000), linear-gradient(#000 0 0) content-box; 25 | mask: var(--_m); 26 | mask-composite: subtract; 27 | animation: loader 1s infinite linear; 28 | } 29 | 30 | @keyframes loader { 31 | to { 32 | transform: rotate(1turn) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/components/loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import './Loader.css'; 2 | 3 | interface Props { 4 | size?: number 5 | overlay?: boolean; 6 | show: boolean; 7 | } 8 | 9 | export const Loader = ({ size = 5, overlay = false, show = true }: Props) => { 10 | 11 | if (!show) { 12 | return <>; 13 | } 14 | 15 | const style: React.CSSProperties = { 16 | width: `${size * 25}px` 17 | }; 18 | 19 | const Spinner = () => { 20 | return
; 21 | }; 22 | 23 | const Overlay = ({ children }: React.PropsWithChildren) => { 24 | return ( 25 |
26 |
27 | {children} 28 |
29 |
30 | ); 31 | } 32 | 33 | return overlay ? : ; 34 | } -------------------------------------------------------------------------------- /client/src/components/toast/Toast.css: -------------------------------------------------------------------------------- 1 | .toast-container { 2 | position: fixed; 3 | bottom: 0; 4 | right: 0; 5 | min-width: 400px; 6 | padding: 1em; 7 | z-index: 100; 8 | } 9 | 10 | .toast { 11 | animation: slide-in-bottom 0.5s ease-out forwards; 12 | padding: 1em; 13 | margin-bottom: 0.5em; 14 | border-radius: 5px; 15 | user-select: none; 16 | display: flex; 17 | flex-direction: row; 18 | justify-content: center; 19 | cursor: pointer; 20 | } 21 | 22 | .toast.success { 23 | background-color: var(--color-success); 24 | } 25 | 26 | .toast.info { 27 | background-color: var(--color-info); 28 | } 29 | 30 | .toast.warning { 31 | background-color: var(--color-warning); 32 | } 33 | 34 | .toast.error { 35 | background-color: var(--color-error); 36 | } 37 | 38 | .toast-message { 39 | color: var(--color-text-dark); 40 | } 41 | 42 | @keyframes slide-in-bottom { 43 | 0% { 44 | opacity: 0; 45 | transform: translateY(100%); 46 | } 47 | 48 | 50% { 49 | opacity: 1; 50 | } 51 | 52 | 100% { 53 | opacity: 1; 54 | transform: translateY(0); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/src/components/toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | 3 | interface ToastProps extends Partial { 4 | onClose: (id: string) => void; 5 | } 6 | 7 | export const Toast = (props: ToastProps) => { 8 | const { id = '', message, type = 'success', duration = 10, onClose } = props; 9 | 10 | const className = classnames('toast', { 11 | 'info': type === 'info', 12 | 'success': type === 'success', 13 | 'error': type === 'error', 14 | 'warning': type === 'warning', 15 | }); 16 | 17 | setTimeout(() => { 18 | onClose(id); 19 | }, duration * 1000); 20 | 21 | return ( 22 |
onClose(id)}> 23 | {message} 24 |
25 | ) 26 | } -------------------------------------------------------------------------------- /client/src/components/toast/ToastContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Toast } from './Toast'; 2 | import { useAppDispatch } from '@/hooks'; 3 | 4 | import './Toast.css'; 5 | 6 | type ToastContainerProps = { 7 | toasts: Toast[]; 8 | }; 9 | 10 | export const ToastContainer = ({toasts}:ToastContainerProps) => { 11 | const dispatch = useAppDispatch(); 12 | 13 | const onClose = (id: string) => { 14 | // rather import action creator from store, keep it simple 15 | // and self-contained 16 | dispatch({ 17 | type: 'app/closeToast', 18 | payload: id 19 | }); 20 | }; 21 | 22 | return ( 23 |
24 | {toasts.map((toast, n) => )} 25 |
26 | ); 27 | } -------------------------------------------------------------------------------- /client/src/hooks/app.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux'; 2 | import type { TypedUseSelectorHook } from 'react-redux'; 3 | import type { RootState, AppDispatch } from '@/app/store'; 4 | 5 | import { toast } from '@/app/appSlice'; 6 | 7 | export const useAppDispatch: () => AppDispatch = useDispatch; 8 | export const useAppSelector: TypedUseSelectorHook = useSelector; 9 | 10 | export const useToast = () => { 11 | const dispatch = useAppDispatch(); 12 | 13 | return (message: string, type: ToastType) => { 14 | dispatch(toast(message, type)); 15 | } 16 | } -------------------------------------------------------------------------------- /client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useAppDispatch, useAppSelector, useToast } from './app'; 2 | export { useTelemetryHub } from './telemetryHub'; 3 | export { useLatestValues } from './latestValues'; 4 | export { useLoader } from './loader'; -------------------------------------------------------------------------------- /client/src/hooks/latestValues.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { selectLoading } from '@/app/appSlice'; 3 | import { selectTelemetry, telemetryReceived } from '@/pages/telemetry/reducers/telemetrySlice'; 4 | import { useAppDispatch, useAppSelector, useLoader } from '@/hooks'; 5 | 6 | import * as API from '@/app/API'; 7 | 8 | type LatestValueResult = [ 9 | data: TelemetryData[], 10 | loading: boolean, 11 | errors: string[] 12 | ]; 13 | 14 | export const useLatestValues = (): LatestValueResult => { 15 | const dispatch = useAppDispatch(); 16 | const loading = useAppSelector(selectLoading); 17 | const data = useAppSelector(selectTelemetry); 18 | const loader = useLoader(); 19 | const [errors, setErrors] = useState([]); 20 | 21 | useEffect(() => { 22 | async function getLatestValues() { 23 | 24 | loader(true); 25 | 26 | const response = await API.latestValues(); 27 | 28 | loader(false); 29 | 30 | if (response.success) { 31 | dispatch(telemetryReceived(response.result)); 32 | } 33 | 34 | if (response.errors.length) { 35 | setErrors(response.errors); 36 | } 37 | } 38 | 39 | getLatestValues(); 40 | 41 | return () => { 42 | // clean up so we don't display stale data when user comes back 43 | dispatch(telemetryReceived([])); 44 | } 45 | 46 | }, []); 47 | 48 | return [data, loading, errors]; 49 | } -------------------------------------------------------------------------------- /client/src/hooks/loader.ts: -------------------------------------------------------------------------------- 1 | import { loading } from '@/app/appSlice'; 2 | import { useAppDispatch } from '@/hooks'; 3 | 4 | export const useLoader = () => { 5 | const dispatch = useAppDispatch(); 6 | 7 | return (isLoading: boolean) => { 8 | dispatch(loading(isLoading)); 9 | } 10 | } -------------------------------------------------------------------------------- /client/src/hooks/telemetryHub.ts: -------------------------------------------------------------------------------- 1 | import { HubConnection } from '@microsoft/signalr'; 2 | import { useEffect } from 'react'; 3 | import { getSignalRConnection } from '@/utilities/signalr'; 4 | import { useAppDispatch, useToast } from '@/hooks'; 5 | import { telemetryReceived } from '@/pages/telemetry/reducers/telemetrySlice'; 6 | 7 | export const useTelemetryHub = (connect: boolean) => { 8 | let connection: HubConnection; 9 | const dispatch = useAppDispatch(); 10 | const toast = useToast(); 11 | let received = false; 12 | 13 | 14 | useEffect(() => { 15 | 16 | if (!connect) { 17 | return; 18 | } 19 | 20 | async function subscribe() { 21 | connection = await getSignalRConnection('/signalr/telemetry'); 22 | 23 | connection.on('subscribed', (response: string) => { 24 | console.log(response); 25 | toast(response, 'success'); 26 | }); 27 | 28 | connection.on('telemetry', (telemetryData: TelemetryData[]) => { 29 | if (!received) { 30 | received = true; 31 | toast('Receiving data...', 'info'); 32 | } 33 | dispatch(telemetryReceived(telemetryData)); 34 | }); 35 | 36 | await connection.invoke('subscribe'); 37 | } 38 | 39 | subscribe(); 40 | 41 | return () => { 42 | if (connection) { 43 | connection.invoke('unsubscribe').then(() => { 44 | connection.off('subscribed'); 45 | connection.off('telemetry'); 46 | 47 | const message = 'Unsubscribed from Telemetry Hub'; 48 | console.log(message); 49 | 50 | toast(message, 'success'); 51 | }); 52 | } 53 | } 54 | 55 | }, [connect]); 56 | 57 | } -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | color-scheme: light dark; 6 | color: rgba(255, 255, 255, 0.87); 7 | font-synthesis: none; 8 | text-rendering: optimizeLegibility; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | -webkit-text-size-adjust: 100%; 12 | /* variables */ 13 | --color-accent: #646cff; 14 | --color-background: #242424; 15 | --color-border: var(--color-accent); 16 | --color-hover: #535bf2; /* should calc from --color-accent */ 17 | --color-info: #61c9e4; 18 | --color-warning: #e4be61; 19 | --color-error: #e47c61; 20 | --color-success: #88e451; 21 | --color-text-dark: #242424; 22 | --color-active: #FFF764; 23 | --color-input-background: #1a1a1a; 24 | } 25 | 26 | body, 27 | html { 28 | height: 100vh; 29 | margin: 0; 30 | padding: 0; 31 | box-sizing: border-box; 32 | background-color: var(--color-background); 33 | } 34 | 35 | body { 36 | display: flex; 37 | } 38 | 39 | a { 40 | font-weight: 500; 41 | color: var(--color-accent); 42 | text-decoration: inherit; 43 | } 44 | 45 | a:hover { 46 | color: var(--color-hover); 47 | } 48 | 49 | h1 { 50 | font-size: 3.2em; 51 | line-height: 1.1; 52 | } 53 | 54 | @media (prefers-color-scheme: light) { 55 | :root { 56 | color: #213547; 57 | background-color: #ffffff; 58 | } 59 | a:hover { 60 | color: #747bff; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import { Provider } from 'react-redux'; 4 | import App from './App'; 5 | import store from './app/store'; 6 | 7 | import './index.css'; 8 | 9 | createRoot(document.getElementById('root') as HTMLElement).render( 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | -------------------------------------------------------------------------------- /client/src/pages/auth/Auth.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; 2 | import { Login } from './Login'; 3 | import { Register } from './Register'; 4 | 5 | export const Auth = () => { 6 | return ( 7 | 8 | 10 | 11 |
12 | }> 13 | } /> 14 | } /> 15 | } /> 16 | 17 | 18 | ); 19 | } 20 | 21 | -------------------------------------------------------------------------------- /client/src/pages/auth/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { NavLink, useNavigate } from 'react-router-dom'; 3 | import { useAppSelector, useAppDispatch, useToast } from '@/hooks'; 4 | import { selectRegistered, loggedIn } from '@/app/appSlice'; 5 | import * as API from '@/app/API'; 6 | import { Button, Input } from '@/components'; 7 | 8 | export const Login = () => { 9 | const dispatch = useAppDispatch(); 10 | const navigate = useNavigate(); 11 | const toast = useToast(); 12 | const [email, setEmail] = useState(''); 13 | const [password, setPassword] = useState(''); 14 | const registered = useAppSelector(selectRegistered); 15 | 16 | const isValid = !!email && email.length > 1 && !!password && password.length > 1; 17 | 18 | const onSubmit = async () => { 19 | const response = await API.login(email, password); 20 | 21 | if (response.success) { 22 | dispatch(loggedIn(response.result!)); 23 | navigate('/home'); 24 | } else { 25 | response.errors.forEach(error => { 26 | toast(error, 'error'); 27 | }); 28 | } 29 | } 30 | 31 | return ( 32 | <> 33 |

Login

34 |
35 |
36 | 42 |
43 |
44 | 51 |
52 | 56 |
57 | 58 | {!registered && 59 |
60 | Register 61 |
62 | } 63 | 64 | ); 65 | } -------------------------------------------------------------------------------- /client/src/pages/auth/Register.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { NavLink, useNavigate } from 'react-router-dom'; 3 | import { useAppDispatch, useToast } from '@/hooks'; 4 | import { registered} from '@/app/appSlice'; 5 | import { Button, Input } from '@/components'; 6 | import * as API from '@/app/API'; 7 | 8 | export const Register = () => { 9 | const dispatch = useAppDispatch(); 10 | const navigate = useNavigate(); 11 | const toast = useToast(); 12 | const [email, setEmail] = useState(''); 13 | const [userName, setUserName] = useState(''); 14 | const [password, setPassword] = useState(''); 15 | const [confirm, setConfirm] = useState(''); 16 | 17 | const isValid = !!email?.length && !!password?.length && password === confirm; 18 | 19 | const onSubmit = async () => { 20 | const response = await API.register(email, userName, password); 21 | 22 | if (response.success) { 23 | // a litty dispatchy 24 | toast('Registration complete!', 'success'); 25 | dispatch(registered(true)); 26 | navigate('/auth/login'); 27 | } else { 28 | response.errors.forEach(error => { 29 | toast(error, 'error'); 30 | }); 31 | } 32 | }; 33 | 34 | return ( 35 | <> 36 |

Register

37 |
38 |
39 | 45 |
46 |
47 | 53 |
54 |
55 | 62 |
63 |
64 | 71 |
72 | 76 |
77 | 78 |
79 | Cancel 80 |
81 | 82 | ); 83 | } -------------------------------------------------------------------------------- /client/src/pages/home/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector, useAppDispatch } from '@/hooks'; 2 | import { increment, selectCount } from './reducers/counterSlice'; 3 | import { Button } from '@/components'; 4 | 5 | export const Home = () => { 6 | // redux...just because 7 | const count = useAppSelector(selectCount); 8 | const dispatch = useAppDispatch(); 9 | 10 | const onClick = () => { 11 | dispatch(increment()); 12 | } 13 | 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /client/src/pages/home/reducers/counterSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import type { RootState } from '@/app/store'; 3 | 4 | type CounterState = { 5 | value: number; 6 | }; 7 | 8 | const initialState: CounterState = { 9 | value: 0 10 | }; 11 | 12 | const counterSlice = createSlice({ 13 | name: 'counter', 14 | initialState, 15 | reducers: { 16 | increment: (state) => { 17 | state.value += 1; 18 | } 19 | } 20 | }); 21 | 22 | export const { increment } = counterSlice.actions; 23 | 24 | export const selectCount = (state: RootState) => state.counter.value; 25 | 26 | export default counterSlice.reducer; -------------------------------------------------------------------------------- /client/src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { Home } from './home/Home'; 2 | export { Telemetry } from './telemetry/Telemetry'; 3 | export { Auth } from './auth/Auth'; 4 | -------------------------------------------------------------------------------- /client/src/pages/telemetry/Telemetry.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { formatDateTime } from '@/utilities/datetime'; 3 | import { useToast } from '@/hooks'; 4 | import { Loader } from '@/components'; 5 | import { useTelemetryHub, useLatestValues } from '@/hooks'; 6 | 7 | export const Telemetry = () => { 8 | // a real world scenario is that telemetry may be coming in at 9 | // various intervals(every 30 secs, 5 minutes, etc) so we grab 10 | // the latest values from the db so that the user can see 11 | // something 12 | // 13 | // this seems a little wonkey but this feels more composable than using 14 | // the thunk here 15 | const toast = useToast(); 16 | const [data, loading, errors ] = useLatestValues(); 17 | const ready = data?.length; 18 | 19 | useEffect(() => { 20 | errors.forEach(error => toast(error, 'error')); 21 | }, [errors]); 22 | 23 | useTelemetryHub(!!ready); 24 | 25 | return ( 26 | loading ? 27 | : ready ? 28 |
29 |

Telemetry Data

30 |

(updated every 5 sec)

31 |
32 | 33 |
34 |
35 | :
36 | ) 37 | 38 | }; 39 | 40 | const TelemetryTable = (props: { data: TelemetryData[] }) => { 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {props.data.map((data, i) => 53 | 54 | 55 | 56 | 57 | 58 | 59 | )} 60 | 61 |
TimestampSensorValueUnit
{formatDateTime(data.timestamp)}{data.sensor}{data.value}{data.unit}
62 | ) 63 | } -------------------------------------------------------------------------------- /client/src/pages/telemetry/reducers/telemetrySlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import type { RootState } from '@/app/store'; 3 | 4 | type TelemetryState = { 5 | data: TelemetryData[]; 6 | }; 7 | 8 | const initialState: TelemetryState = { 9 | data: [] 10 | }; 11 | 12 | const telemetrySlice = createSlice({ 13 | name: 'telemetry', 14 | initialState, 15 | reducers: { 16 | telemetryReceived: (state, action) => { 17 | state.data = action.payload; 18 | } 19 | } 20 | }); 21 | 22 | export const { telemetryReceived } = telemetrySlice.actions; 23 | 24 | export const selectTelemetry = (state: RootState) => state.telemetry.data; 25 | 26 | export default telemetrySlice.reducer; -------------------------------------------------------------------------------- /client/src/pages/telemetry/reducers/telemetryThunk.ts: -------------------------------------------------------------------------------- 1 | import type { AppThunk } from '@/app/store'; 2 | import * as API from '@/app/API'; 3 | import { loading, toast } from '@/app/appSlice'; 4 | import { telemetryReceived } from './telemetrySlice'; 5 | 6 | export { telemetryReceived }; 7 | 8 | // lasted values as thunk (not used here) 9 | // 10 | // use RTK Query in real life... 11 | // and don't dispatch toasts from thunks 12 | // and don't do this 13 | export const latestValues = (): AppThunk => { 14 | return async dispatch => { 15 | try { 16 | 17 | let loaded = false; 18 | 19 | // prevent loader flashing 20 | setTimeout(() => { 21 | if (!loaded) { 22 | dispatch(loading(true)); 23 | } 24 | }, 500); 25 | 26 | const response = await API.latestValues(); 27 | 28 | loaded = true; 29 | 30 | dispatch(loading(false)); 31 | 32 | if (response.success) { 33 | dispatch(telemetryReceived(response.result)); 34 | } 35 | 36 | response.errors.forEach(error => { 37 | dispatch(toast(error, 'error')); 38 | }); 39 | 40 | } catch (err) { 41 | console.error(err); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /client/src/types/app.d.ts: -------------------------------------------------------------------------------- 1 | type SuccessResult = { 2 | success: true; 3 | result: T; 4 | errors: string[]; 5 | } 6 | 7 | type ErrorResult = { 8 | success: false; 9 | result: null; 10 | errors: string[]; 11 | } 12 | 13 | type StandardResult = SuccessResult | ErrorResult; 14 | type StandardPromise = Promise>; 15 | type StandardApiCall = (...args: any) => StandardPromise; 16 | 17 | type ApiAsyncResult = { 18 | result: T; 19 | error: string | null; 20 | loading: boolean; 21 | ready: boolean; 22 | setResult: (result: T) => void; 23 | }; 24 | 25 | type ToastType = 'success' | 'error' | 'info' | 'warning'; 26 | 27 | type Toast = { 28 | id?: string; 29 | message: string; 30 | duration?: number; 31 | type: ToastType; 32 | } 33 | 34 | type LoginResponse = { 35 | email: string; 36 | userName: string; 37 | token: string; 38 | } -------------------------------------------------------------------------------- /client/src/types/telemetry.d.ts: -------------------------------------------------------------------------------- 1 | type TelemetryData = { 2 | timestamp: string; 3 | sensor: string; 4 | value: number; 5 | unit: string; 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/utilities/datetime.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export function formatDateTime(date: Date | string): string { 4 | return !!date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : ''; 5 | } -------------------------------------------------------------------------------- /client/src/utilities/http.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import type { AxiosError, AxiosRequestConfig } from 'axios'; 3 | import store from '@/app/store'; 4 | 5 | function getToken() { 6 | const state = store.getState(); 7 | return state.app.token; 8 | } 9 | 10 | function getErrorResult(error: string): ErrorResult { 11 | return { 12 | success: false, 13 | errors: [error], 14 | result: null 15 | } 16 | } 17 | 18 | function request(config: AxiosRequestConfig): StandardPromise { 19 | return axios(config).then(response => { 20 | return response.data as StandardResult 21 | }).catch((err: AxiosError,void>) => { 22 | if (err.response?.status === 401) { 23 | return getErrorResult('You are not authorized.'); 24 | } 25 | return getErrorResult('An unknown error has occurred.') }); 26 | } 27 | 28 | function config(method: string): AxiosRequestConfig { 29 | const token = getToken(); 30 | 31 | return { 32 | method, 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | ...(!!token && {'Authorization': `Bearer ${token}`}) 36 | } 37 | } 38 | } 39 | 40 | export async function post(url: string, data: any): StandardPromise { 41 | return request({...config('POST'), 42 | url, 43 | data 44 | }); 45 | } 46 | 47 | export async function get(url: string): StandardPromise { 48 | return request({...config('GET'), 49 | url, 50 | }); 51 | } 52 | 53 | -------------------------------------------------------------------------------- /client/src/utilities/signalr.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JsonHubProtocol, 3 | HubConnection, 4 | HubConnectionState, 5 | HubConnectionBuilder, 6 | LogLevel, 7 | IHttpConnectionOptions, 8 | HttpTransportType 9 | } from '@microsoft/signalr'; 10 | 11 | import store from '@/app/store'; 12 | 13 | const isDev = process.env.NODE_ENV === 'development'; 14 | 15 | const getToken = (): string => { 16 | const state = store.getState(); 17 | return state.app.token!; 18 | } 19 | 20 | const startSignalRConnection = async (connection: HubConnection) => { 21 | try { 22 | await connection.start(); 23 | console.assert(connection.state === HubConnectionState.Connected); 24 | console.log('SignalR connection established', connection.baseUrl); 25 | } catch (err) { 26 | console.assert(connection.state === HubConnectionState.Disconnected); 27 | console.error('SignalR Connection Error: ', err); 28 | setTimeout(() => startSignalRConnection(connection), 5000); 29 | } 30 | }; 31 | 32 | export const getSignalRConnection = async (url: string) => { 33 | 34 | const options: IHttpConnectionOptions = { 35 | logMessageContent: isDev, 36 | logger: isDev ? LogLevel.Warning : LogLevel.Error, 37 | skipNegotiation: true, 38 | transport: HttpTransportType.WebSockets, 39 | accessTokenFactory: () => getToken() 40 | }; 41 | 42 | console.log('SignalR: Creating new connection.'); 43 | 44 | const connection = new HubConnectionBuilder() 45 | .withUrl(url, options) 46 | .withAutomaticReconnect() 47 | .withHubProtocol(new JsonHubProtocol()) 48 | .configureLogging(LogLevel.Information) 49 | .build(); 50 | 51 | // Note: to keep the connection open the serverTimeout should be 52 | // larger than the KeepAlive value that is set on the server 53 | // 54 | // keepAliveIntervalInMilliseconds default is 15000 and we are using default 55 | // serverTimeoutInMilliseconds default is 30000 and we are using 60000 set below 56 | connection.serverTimeoutInMilliseconds = 60000; 57 | connection.keepAliveIntervalInMilliseconds = 15000; 58 | 59 | // re-establish the connection if connection dropped 60 | connection.onclose(error => { 61 | console.assert(connection.state === HubConnectionState.Disconnected); 62 | if (!!error) { 63 | console.log('SignalR: connection was closed due to error.', error); 64 | } else { 65 | console.log('SignalR: connection was closed.'); 66 | } 67 | }); 68 | 69 | connection.onreconnecting(error => { 70 | console.assert(connection.state === HubConnectionState.Reconnecting); 71 | console.log('SignalR: connection lost due. Reconnecting...', error); 72 | }); 73 | 74 | connection.onreconnected(connectionId => { 75 | console.assert(connection.state === HubConnectionState.Connected); 76 | console.log('SignalR: connection reestablished. Connected with connectionId', connectionId); 77 | }); 78 | 79 | await startSignalRConnection(connection); 80 | 81 | return connection; 82 | 83 | }; 84 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ "DOM", "DOM.Iterable", "ESNext" ], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "sourceMap": true, 18 | "jsx": "react-jsx", 19 | "baseUrl": "./src", 20 | "paths": { 21 | "@/*": [ "./*" ] 22 | } 23 | }, 24 | "include": ["src"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react-swc'; 3 | import mkcert from 'vite-plugin-mkcert'; 4 | import browserslistToEsbuild from 'browserslist-to-esbuild'; 5 | import { fileURLToPath, URL } from 'url'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | build: { 10 | target: browserslistToEsbuild() 11 | }, 12 | //css: postcss /* loaded from postcss.config.cjs */ 13 | plugins: [ 14 | react(), 15 | mkcert() 16 | ], 17 | resolve: { 18 | alias: { 19 | '@': fileURLToPath(new URL('./src', import.meta.url)) 20 | } 21 | }, 22 | server: { 23 | https: true, 24 | strictPort: true, 25 | port: 3000, 26 | proxy: { 27 | '/api': { 28 | target: 'https://localhost:7200', 29 | secure: false 30 | }, 31 | '/signalr': { 32 | target: 'wss://localhost:7200', 33 | ws: true, 34 | secure: false 35 | }, 36 | } 37 | } 38 | }) -------------------------------------------------------------------------------- /docker-compose.postgres.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | db: 5 | image: postgres:15.2 6 | container_name: cvr-db 7 | restart: always 8 | environment: 9 | - POSTGRES_USER=admin 10 | - POSTGRES_PASSWORD=admin 11 | - POSTGRES_DB=postgres 12 | networks: 13 | - cvr 14 | ports: 15 | - "7432:5432" 16 | volumes: 17 | - db:/var/lib/postgresql/data 18 | - ./sql:/docker-entrypoint-initdb.d 19 | db-admin: 20 | image: dpage/pgadmin4 21 | container_name: cvr-db-admin 22 | networks: 23 | - cvr 24 | ports: 25 | - "7433:80" 26 | environment: 27 | - PGADMIN_DEFAULT_EMAIL=admin@admin.com 28 | - PGADMIN_DEFAULT_PASSWORD=admin 29 | volumes: 30 | - db-admin:/var/lib/pgadmin 31 | 32 | volumes: 33 | db: 34 | driver: local 35 | db-admin: 36 | driver: local 37 | 38 | networks: 39 | cvr: 40 | name: cvr_network 41 | 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | api: 5 | image: cvr-api:v1 6 | container_name: cvr-api 7 | restart: always 8 | environment: 9 | - ASPNETCORE_ENVIRONMENT=Development 10 | - ASPNETCORE_HTTPS_PORT=7200 11 | - ASPNETCORE_URLS=https://+:443 12 | - ASPNETCORE_Kestrel__Certificates__Default__Password=supersecret 13 | - ASPNETCORE_Kestrel__Certificates__Default__Path=/cert/API.pfx 14 | networks: 15 | - cvr 16 | ports: 17 | - "7200:443" 18 | volumes: 19 | # Windows using Linux containers 20 | # See https://github.com/dotnet/dotnet-docker/blob/main/samples/run-aspnetcore-https-development.md 21 | - ${USERPROFILE}\.aspnet\https:/cert/ 22 | build: 23 | context: . 24 | dockerfile: Dockerfile 25 | depends_on: 26 | - db 27 | db: 28 | image: postgres:15.2 29 | container_name: cvr-db 30 | restart: always 31 | environment: 32 | - POSTGRES_USER=admin 33 | - POSTGRES_PASSWORD=admin 34 | - POSTGRES_DB=postgres 35 | networks: 36 | - cvr 37 | ports: 38 | - "7432:5432" 39 | volumes: 40 | - db:/var/lib/postgresql/data 41 | - ./sql:/docker-entrypoint-initdb.d 42 | db-admin: 43 | image: dpage/pgadmin4 44 | container_name: cvr-db-admin 45 | networks: 46 | - cvr 47 | ports: 48 | - "7433:80" 49 | environment: 50 | - PGADMIN_DEFAULT_EMAIL=admin@admin.com 51 | - PGADMIN_DEFAULT_PASSWORD=admin 52 | volumes: 53 | - db-admin:/var/lib/pgadmin 54 | 55 | volumes: 56 | db: 57 | driver: local 58 | db-admin: 59 | driver: local 60 | 61 | networks: 62 | cvr: 63 | name: cvr_network 64 | 65 | -------------------------------------------------------------------------------- /sql/01-seed-identity.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( 2 | "MigrationId" character varying(150) NOT NULL, 3 | "ProductVersion" character varying(32) NOT NULL, 4 | CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") 5 | ); 6 | 7 | START TRANSACTION; 8 | 9 | CREATE TABLE "AspNetUsers" ( 10 | "Id" text NOT NULL, 11 | "UserName" character varying(256) NULL, 12 | "NormalizedUserName" character varying(256) NULL, 13 | "Email" character varying(256) NULL, 14 | "NormalizedEmail" character varying(256) NULL, 15 | "EmailConfirmed" boolean NOT NULL, 16 | "PasswordHash" text NULL, 17 | "SecurityStamp" text NULL, 18 | "ConcurrencyStamp" text NULL, 19 | "PhoneNumber" text NULL, 20 | "PhoneNumberConfirmed" boolean NOT NULL, 21 | "TwoFactorEnabled" boolean NOT NULL, 22 | "LockoutEnd" timestamp with time zone NULL, 23 | "LockoutEnabled" boolean NOT NULL, 24 | "AccessFailedCount" integer NOT NULL, 25 | CONSTRAINT "PK_AspNetUsers" PRIMARY KEY ("Id") 26 | ); 27 | 28 | CREATE TABLE "AspNetUserClaims" ( 29 | "Id" integer GENERATED BY DEFAULT AS IDENTITY, 30 | "UserId" text NOT NULL, 31 | "ClaimType" text NULL, 32 | "ClaimValue" text NULL, 33 | CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY ("Id"), 34 | CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE 35 | ); 36 | 37 | CREATE TABLE "AspNetUserLogins" ( 38 | "LoginProvider" text NOT NULL, 39 | "ProviderKey" text NOT NULL, 40 | "ProviderDisplayName" text NULL, 41 | "UserId" text NOT NULL, 42 | CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"), 43 | CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE 44 | ); 45 | 46 | CREATE TABLE "AspNetUserTokens" ( 47 | "UserId" text NOT NULL, 48 | "LoginProvider" text NOT NULL, 49 | "Name" text NOT NULL, 50 | "Value" text NULL, 51 | CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"), 52 | CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE 53 | ); 54 | 55 | CREATE INDEX "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId"); 56 | 57 | CREATE INDEX "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId"); 58 | 59 | CREATE INDEX "EmailIndex" ON "AspNetUsers" ("NormalizedEmail"); 60 | 61 | CREATE UNIQUE INDEX "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName"); 62 | 63 | INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") 64 | VALUES ('20230320015352_initial', '7.0.4'); 65 | 66 | COMMIT; 67 | 68 | 69 | --------------------------------------------------------------------------------