{
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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | })
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/.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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/assets/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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/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/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/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 | | Timestamp |
46 | Sensor |
47 | Value |
48 | Unit |
49 |
50 |
51 |
52 | {props.data.map((data, i) =>
53 |
54 | | {formatDateTime(data.timestamp)} |
55 | {data.sensor} |
56 | {data.value} |
57 | {data.unit} |
58 |
59 | )}
60 |
61 |
62 | )
63 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | }
--------------------------------------------------------------------------------
/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;
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
77 |
78 |
79 | Cancel
80 |
81 | >
82 | );
83 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------