├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── api.sln ├── api.test ├── Controller │ └── Tests.cs ├── ControllerTests.cs ├── NuGet.Config ├── Unit │ └── Tests.cs └── api.test.csproj ├── api ├── .config │ └── dotnet-tools.json ├── Controllers │ ├── AuthController.cs │ └── ContactController.cs ├── Extensions │ └── StringExtension.cs ├── Middleware │ ├── SpaFallbackMiddleware.cs │ └── SpaFallbackOptions.cs ├── Migrations │ ├── 20171204210645_Initial.Designer.cs │ ├── 20171204210645_Initial.cs │ ├── 20200128145031_1580223026.Designer.cs │ ├── 20200128145031_1580223026.cs │ └── DefaultDbContextModelSnapshot.cs ├── Models │ ├── ApplicationUser.cs │ ├── Contact.cs │ ├── DefaultDbContext.cs │ ├── DefaultDbContextInitializer.cs │ └── DesignTimeDefaultDbContext.cs ├── NuGet.Config ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ ├── EmailSender.cs │ ├── EmailSenderOptions.cs │ ├── IEmailSender.cs │ └── JwtOptions.cs ├── Startup.cs ├── ViewModels │ ├── ConfirmEmail.cs │ └── NewUser.cs ├── api.csproj ├── appsettings.json ├── log │ └── development-20170420.log └── wwwroot │ └── favicon.ico ├── client-react.test ├── build │ └── client-react │ │ └── styles ├── polyfill │ └── localStorage.js ├── tests.tsx ├── tests │ └── Contacts.tsx ├── tsconfig.json └── utils.ts ├── client-react ├── boot.tsx ├── components │ ├── Auth.tsx │ ├── ContactForm.tsx │ ├── Contacts.tsx │ ├── Error.tsx │ ├── Header.tsx │ └── Routes.tsx ├── index.ejs ├── polyfills │ ├── array-find.d.ts │ ├── array-find.ts │ ├── object-assign.d.ts │ └── object-assign.ts ├── services │ ├── Auth.ts │ ├── Contacts.ts │ └── RestUtilities.ts ├── stores │ └── Auth.ts ├── styles │ ├── auth.styl │ ├── contacts.styl │ ├── global.css │ └── styles.d.ts ├── tsconfig.json ├── webpack.config.js └── webpack.config.release.js ├── docker-compose.yml ├── global.json ├── ops ├── README.md ├── config.yml.example ├── deploy.yml ├── group_vars │ └── all ├── library │ └── ghetto_json ├── provision.yml └── roles │ ├── deploy │ ├── defaults │ │ └── main.yml │ ├── meta │ │ └── main.yml │ └── tasks │ │ └── main.yml │ ├── deploy_user │ ├── meta │ │ └── main.yml │ └── tasks │ │ └── main.yml │ ├── dotnetcore │ ├── defaults │ │ └── main.yml │ └── tasks │ │ └── main.yml │ ├── firewall │ ├── handlers │ │ └── main.yml │ └── tasks │ │ └── main.yml │ ├── nginx │ ├── defaults │ │ └── main.yml │ ├── handlers │ │ └── main.yml │ ├── tasks │ │ └── main.yml │ └── templates │ │ └── etc_nginx_sites-available.conf.j2 │ ├── postgresql │ ├── LICENSE │ ├── README.md │ ├── defaults │ │ └── main.yml │ ├── handlers │ │ └── main.yml │ ├── tasks │ │ ├── backup.yml │ │ ├── configure.yml │ │ ├── install.yml │ │ └── main.yml │ ├── templates │ │ └── pgsql_backup.sh.j2 │ └── vars │ │ └── main.yml │ ├── s3cmd │ ├── meta │ │ └── main.yml │ ├── tasks │ │ └── main.yml │ └── templates │ │ └── s3cfg.j2 │ ├── ssl │ ├── defaults │ │ └── main.yml │ ├── meta │ │ └── main.yml │ ├── tasks │ │ └── main.yml │ └── templates │ │ └── config.j2 │ └── supervisor │ ├── defaults │ └── main.yml │ ├── handlers │ └── main.yml │ ├── tasks │ └── main.yml │ └── templates │ └── etc_supervisor_conf.d_app_name.conf.j2 ├── package-lock.json ├── package.json └── scripts └── create-migration.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | indent_style = space 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_size = 2 10 | 11 | [*.{js,ts,tsx}] 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [bradymholt] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # dotnet core 4 | bin/ 5 | obj/ 6 | project.lock.json 7 | NuGetScratch/ 8 | 9 | api/wwwroot/** 10 | !api/wwwroot/scratch.html 11 | !api/wwwroot/favicon.ico 12 | 13 | # node 14 | node_modules/ 15 | typings/ 16 | npm-debug.log 17 | 18 | # client-react 19 | client-react/components/*.js 20 | client-react.test/build 21 | !client-react.test/build/client-react/styles/ 22 | 23 | # ops 24 | ops/hosts 25 | ops/config.yml 26 | ops/*.retry 27 | 28 | # other 29 | *.js.map 30 | 31 | # IDE 32 | .idea/ 33 | .vs/ 34 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug api/ (server)", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | "program": "${workspaceRoot}/api/bin/Debug/netcoreapp3.1/api.dll", 10 | "args": [], 11 | "env": { 12 | "ASPNETCORE_ENVIRONMENT": "Development", 13 | "NODE_PATH": "../node_modules/" 14 | }, 15 | "cwd": "${workspaceRoot}/api", 16 | "externalConsole": false, 17 | "stopAtEntry": false, 18 | "internalConsoleOptions": "openOnSessionStart" 19 | }, 20 | { 21 | "name": "Debug client-react.test/ (Mocha tests)", 22 | "type": "node", 23 | "request": "launch", 24 | "protocol": "inspector", 25 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 26 | "stopOnEntry": false, 27 | "args": [ 28 | "--require", 29 | "ignore-styles", 30 | "--recursive", 31 | "client-react.test/build/client-react.test" 32 | ], 33 | "cwd": "${workspaceRoot}", 34 | "preLaunchTask": "pretest:client", 35 | "runtimeArgs": [ 36 | "--nolazy" 37 | ], 38 | "env": { 39 | "NODE_ENV": "development" 40 | }, 41 | "sourceMaps": true 42 | }, 43 | { 44 | "name": ".NET Core Attach", 45 | "type": "coreclr", 46 | "request": "attach", 47 | "processId": "${command:pickProcess}" 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/bin": true, 9 | "**/obj": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "0.1.0", 5 | "command": "npm", 6 | "isShellCommand": true, 7 | "showOutput": "always", 8 | "suppressTaskName": true, 9 | "tasks": [ 10 | { 11 | "taskName": "build", 12 | "args": [ 13 | "run", 14 | "build" 15 | ], 16 | "isBuildCommand": true 17 | }, 18 | { 19 | "taskName": "test", 20 | "args": [ 21 | "run", 22 | "test" 23 | ], 24 | "isTestCommand": true 25 | }, 26 | { 27 | "taskName": "pretest:client", 28 | "args":[ 29 | "run", 30 | "pretest:client" 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Brady Holt 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 / React SPA Template App 2 | 3 | 4 | 5 |
6 | 7 |  **Would you take a quick second and ⭐️ my repo?** 8 | 9 |
10 | 11 | 12 | This app is a template application using ASP.NET Core 3.1 for a REST/JSON API server and React for a web client. 13 | 14 | ![screen recording 2017-06-10 at 04 12 pm](https://user-images.githubusercontent.com/759811/27006360-bd3b8152-4df7-11e7-9011-f22204abe4d5.gif) 15 | 16 | ## Overview of Stack 17 | - Server 18 | - ASP.NET Core 3.1 19 | - PostgreSQL 10 20 | - Entity Framework Core w/ EF Migrations 21 | - JSON Web Token (JWT) authorization 22 | - Docker used for development PostgreSQL database and MailCatcher server 23 | - Client 24 | - React 16 25 | - Webpack for asset bundling and HMR (Hot Module Replacement) 26 | - CSS Modules 27 | - Fetch API for REST requests 28 | - Testing 29 | - xUnit for .NET Core 30 | - Enzyme for React 31 | - MailCatcher for development email delivery 32 | - DevOps 33 | - Ansible playbook for provisioning (Nginx reverse proxy, SSL via Let's Encrypt, PostgreSQL backups to S3) 34 | - Ansible playbook for deployment 35 | 36 | ## Demo 37 | 38 | [![Demo Video](https://cloud.githubusercontent.com/assets/759811/26319096/4075a7e2-3ee3-11e7-8017-26df7b278b27.png)](https://www.youtube.com/watch?v=xh5plRGg3Nc) 39 | 40 | ## Setup 41 | 42 | 1. Install the following: 43 | - [.NET Core 3.1](https://www.microsoft.com/net/core) 44 | - [Node.js >= v8](https://nodejs.org/en/download/) 45 | - [Ansible >= 2.6](http://docs.ansible.com/ansible/intro_installation.html) 46 | - [Docker](https://docs.docker.com/engine/installation/) 47 | 2. Run `npm install && npm start` 48 | 3. Open browser and navigate to [http://localhost:5000](http://localhost:5000). 49 | 50 | This template was developed and tested on macOS Sierra but should run on Windows (for development) as well. If you experience any issues getting it to run on Windows and work through them, please submit a PR! The production provisioning and deployment scripts (`provision:prod` and `deploy:prod`) use Ansible and require a Linux/Ubuntu >= 16.04 target host. 51 | 52 | ## Scripts 53 | 54 | ### `npm install` 55 | 56 | When first cloning the repo or adding new dependencies, run this command. This will: 57 | 58 | - Install Node dependencies from package.json 59 | - Install .NET Core dependencies from api/api.csproj and api.test/api.test.csproj (using dotnet restore) 60 | 61 | ### `npm start` 62 | 63 | To start the app for development, run this command. This will: 64 | 65 | - Run `docker-compose up` to ensure the PostgreSQL and MailCatcher Docker images are up and running 66 | - Run `dotnet watch run` which will build the app (if changed), watch for changes and start the web server on http://localhost:5000 67 | - Run Webpack dev middleware with HMR via [ASP.NET JavaScriptServices](https://github.com/aspnet/JavaScriptServices) 68 | 69 | ### `npm run migrate` 70 | 71 | After making changes to Entity Framework models in `api/Models/`, run this command to generate and run a migration on the database. A timestamp will be used for the migration name. 72 | 73 | ### `npm test` 74 | 75 | This will run the xUnit tests in api.test/ and the Mocha/Enzyme tests in client-react.test/. 76 | 77 | ### `npm run provision:prod` 78 | 79 | _Before running this script, you need to create an ops/config.yml file first. See the [ops README](ops/) for instructions._ 80 | 81 | This will run the ops/provision.yml Ansible playbook and provision hosts in ops/hosts inventory file. Ubuntu 16.04 (Xenial) and Ubuntu 18.04 (Bionic) is supported and tested. 82 | 83 | This prepares the hosts to recieve deployments by doing the following: 84 | - Install Nginx 85 | - Generate a SSL certificate from [Let's Encrypt](https://letsencrypt.org/) and configure Nginx to use it 86 | - Install .Net Core 87 | - Install Supervisor (will run/manage the ASP.NET app) 88 | - Install PostgreSQL 89 | - Setup a cron job to automatically backup the PostgreSQL database, compress it, and upload it to S3. 90 | - Setup UFW (firewall) to lock everything down except inbound SSH and web traffic 91 | - Create a deploy user, directory for deployments and configure Nginx to serve from this directory 92 | 93 | ### `npm run deploy:prod` 94 | 95 | _Before running this script, you need to create a ops/config.yml file first. See the [ops README](ops/) for instructions._ 96 | 97 | This script will: 98 | - Build release Webpack bundles 99 | - Package the .NET Core application in Release mode (dotnet publish) 100 | - Run the ops/deploy.yml Ansible playbook to deploy this app to hosts in /ops/config.yml inventory file. 101 | 102 | This does the following: 103 | - Copies the build assets to the remote host(s) 104 | - Updates the `appsettings.json` file with PostgreSQL credentials specified in ops/group_vars/all file and the app URL (needed for JWT tokens) 105 | - Restarts the app so that changes will be picked up 106 | 107 | Entity Framework Migrations are [automatically applied upon startup](https://github.com/bradymholt/aspnet-core-react-template/blob/master/api/Program.cs#L23-L24) so they will run when the app restarts. 108 | 109 | ## Development Email Delivery 110 | 111 | This template includes a [MailCatcher](https://mailcatcher.me/) Docker image so that when email is sent during development (i.e. new user registration), it can be viewed 112 | in the MailCacher web interface at [http://localhost:1080/](http://localhost:1080/). 113 | 114 | ## Older Versions 115 | 116 | This template was originally created on .NET Core 1.0 and has been upgraded with new versions of .NET Core. Older versions can be found on the [Releases](https://github.com/bradymholt/aspnet-core-react-template/releases) page. 117 | 118 | ## Visual Studio Code config 119 | 120 | This project has [Visual Studio Code](https://code.visualstudio.com/) tasks and debugger launch config located in .vscode/. 121 | 122 | ### Tasks 123 | 124 | - **Command+Shift+B** - Runs the "build" task which builds the api/ project 125 | - **Command+Shift+T** - Runs the "test" task which runs the xUnit tests in api.test/ and Mocha/Enzyme tests in client-react.test/. 126 | 127 | ### Debug Launcher 128 | 129 | With the following debugger launch configs, you can set breakpoints in api/ or the the Mocha tests in client-react.test/ and have full debugging support. 130 | 131 | - **Debug api/ (server)** - Runs the vscode debugger (breakpoints) on the api/ .NET Core app 132 | - **Debug client-react.test/ (Mocha tests)** - Runs the vscode debugger on the client-react.test/ Mocha tests 133 | 134 | ## Credit 135 | 136 | The following resources were helpful in setting up this template: 137 | 138 | - [Sample for implementing Authentication with a React Flux app and JWTs](https://github.com/auth0-blog/react-flux-jwt-authentication-sample) 139 | - [Angular 2, React, and Knockout apps on ASP.NET Core](http://blog.stevensanderson.com/2016/05/02/angular2-react-knockout-apps-on-aspnet-core/) 140 | - [Setting up ASP.NET v5 (vNext) to use JWT tokens (using OpenIddict)](http://capesean.co.za/blog/asp-net-5-jwt-tokens/) 141 | - [Cross-platform Single Page Applications with ASP.NET Core 1.0, Angular 2 & TypeScript](https://chsakell.com/2016/01/01/cross-platform-single-page-applications-with-asp-net-5-angular-2-typescript/) 142 | - [Stack Overflow - Token Based Authentication in ASP.NET Core](http://stackoverflow.com/questions/30546542/token-based-authentication-in-asp-net-core-refreshed) 143 | - [SPA example of a token based authentication implementation using the Aurelia front end framework and ASP.NET core]( https://github.com/alexandre-spieser/AureliaAspNetCoreAuth) 144 | - [A Real-World React.js Setup for ASP.NET Core and MVC5](https://www.simple-talk.com/dotnet/asp-net/a-real-world-react-js-setup-for-asp-net-core-and-mvc) 145 | - [Customising ASP.NET Core Identity EF Core naming conventions for PostgreSQL](https://andrewlock.net/customising-asp-net-core-identity-ef-core-naming-conventions-for-postgresql) 146 | - My own perseverance because this took a _lot_ of time to get right 🤓 147 | -------------------------------------------------------------------------------- /api.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{62842846-868D-4B6E-A191-C37121587533}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api.test", "api.test\api.test.csproj", "{002B4B4F-3A5D-4342-98AB-C50F7147F863}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {62842846-868D-4B6E-A191-C37121587533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {62842846-868D-4B6E-A191-C37121587533}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {62842846-868D-4B6E-A191-C37121587533}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {62842846-868D-4B6E-A191-C37121587533}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {002B4B4F-3A5D-4342-98AB-C50F7147F863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {002B4B4F-3A5D-4342-98AB-C50F7147F863}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {002B4B4F-3A5D-4342-98AB-C50F7147F863}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {002B4B4F-3A5D-4342-98AB-C50F7147F863}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /api.test/Controller/Tests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using app = aspnetCoreReactTemplate; 4 | 5 | namespace Tests.Controller 6 | { 7 | public class Tests 8 | { 9 | // [Fact] 10 | // public void Index_ReturnsAViewResult_WithAListOfBrainstormSessions() 11 | // { 12 | // var controller = new app.Controllers.ContactsController(null); 13 | // var result = (IEnumerable)controller.Get(); 14 | 15 | // Assert.NotEqual(result.Count(), 0); 16 | // } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api.test/ControllerTests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace Tests 4 | { 5 | public class ControllerTests 6 | { 7 | [Fact] 8 | public void Test1() 9 | { 10 | var contact = new aspnetCoreReactTemplate.Models.Contact(); 11 | Assert.True(string.IsNullOrEmpty(contact.Email)); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /api.test/NuGet.Config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /api.test/Unit/Tests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | using app = aspnetCoreReactTemplate; 3 | 4 | namespace Tests.Unit 5 | { 6 | public class Tests 7 | { 8 | [Fact] 9 | public void TestNewContactProperties() 10 | { 11 | var contact = new app.Models.Contact(); 12 | 13 | Assert.True(string.IsNullOrEmpty(contact.LastName)); 14 | Assert.True(string.IsNullOrEmpty(contact.FirstName)); 15 | Assert.True(string.IsNullOrEmpty(contact.Email)); 16 | Assert.True(string.IsNullOrEmpty(contact.Phone)); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api.test/api.test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /api/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-ef": { 6 | "version": "3.1.1", 7 | "commands": [ 8 | "dotnet-ef" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /api/Controllers/AuthController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | using aspnetCoreReactTemplate.Models; 11 | using aspnetCoreReactTemplate.Services; 12 | using aspnetCoreReactTemplate.ViewModels; 13 | using System.Security.Claims; 14 | using System.IdentityModel.Tokens.Jwt; 15 | using Microsoft.IdentityModel.Tokens; 16 | 17 | namespace aspnetCoreReactTemplate.Controllers 18 | { 19 | public class AuthController : Controller 20 | { 21 | private readonly UserManager _userManager; 22 | private readonly IOptions _identityOptions; 23 | private readonly JwtOptions _jwtOptions; 24 | private readonly IEmailSender _emailSender; 25 | private readonly SignInManager _signInManager; 26 | private readonly ILogger _logger; 27 | 28 | public AuthController( 29 | UserManager userManager, 30 | IOptions identityOptions, 31 | IOptions jwtOptions, 32 | IEmailSender emailSender, 33 | SignInManager signInManager, 34 | ILoggerFactory loggerFactory) 35 | { 36 | _userManager = userManager; 37 | _identityOptions = identityOptions; 38 | _jwtOptions = jwtOptions.Value; 39 | _emailSender = emailSender; 40 | _signInManager = signInManager; 41 | _logger = loggerFactory.CreateLogger(); 42 | } 43 | 44 | [AllowAnonymous] 45 | [HttpPost("~/api/auth/login")] 46 | [Produces("application/json")] 47 | public async Task Login(string username, string password) 48 | { 49 | // Ensure the username and password is valid. 50 | var user = await _userManager.FindByNameAsync(username); 51 | if (user == null || !await _userManager.CheckPasswordAsync(user, password)) 52 | { 53 | return BadRequest(new 54 | { 55 | error = "", //OpenIdConnectConstants.Errors.InvalidGrant, 56 | error_description = "The username or password is invalid." 57 | }); 58 | } 59 | 60 | // Ensure the email is confirmed. 61 | if (!await _userManager.IsEmailConfirmedAsync(user)) 62 | { 63 | return BadRequest(new 64 | { 65 | error = "email_not_confirmed", 66 | error_description = "You must have a confirmed email to log in." 67 | }); 68 | } 69 | 70 | _logger.LogInformation($"User logged in (id: {user.Id})"); 71 | 72 | // Generate and issue a JWT token 73 | var claims = new [] { 74 | new Claim(ClaimTypes.Name, user.Email), 75 | new Claim(JwtRegisteredClaimNames.Sub, user.Email), 76 | new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) 77 | }; 78 | 79 | var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.key)); 80 | var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); 81 | 82 | var token = new JwtSecurityToken( 83 | issuer: _jwtOptions.issuer, 84 | audience: _jwtOptions.issuer, 85 | claims: claims, 86 | expires: DateTime.Now.AddMinutes(30), 87 | signingCredentials: creds); 88 | 89 | return Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) }); 90 | } 91 | 92 | [AllowAnonymous] 93 | [HttpPost("~/api/auth/register")] 94 | public async Task Register(NewUser model) 95 | { 96 | if (!ModelState.IsValid) 97 | { 98 | return BadRequest(ModelState); 99 | } 100 | 101 | var user = new ApplicationUser { UserName = model.username, Email = model.username }; 102 | var result = await _userManager.CreateAsync(user, model.password); 103 | if (result.Succeeded) 104 | { 105 | _logger.LogInformation($"New user registered (id: {user.Id})"); 106 | 107 | if (!user.EmailConfirmed) 108 | { 109 | // Send email confirmation email 110 | var confirmToken = await _userManager.GenerateEmailConfirmationTokenAsync(user); 111 | var emailConfirmUrl = Url.RouteUrl("ConfirmEmail", new { uid = user.Id, token = confirmToken }, this.Request.Scheme); 112 | await _emailSender.SendEmailAsync(model.username, "Please confirm your account", 113 | $"Please confirm your account by clicking this link." 114 | ); 115 | 116 | _logger.LogInformation($"Sent email confirmation email (id: {user.Id})"); 117 | } 118 | 119 | // Create a new authentication ticket. 120 | //var ticket = await CreateTicket(user); 121 | 122 | _logger.LogInformation($"User logged in (id: {user.Id})"); 123 | 124 | return Ok(); 125 | } 126 | else 127 | { 128 | return BadRequest(new { general = result.Errors.Select(x => x.Description) }); 129 | } 130 | } 131 | 132 | [AllowAnonymous] 133 | [HttpGet("~/api/auth/confirm", Name = "ConfirmEmail")] 134 | public async Task Confirm(string uid, string token) 135 | { 136 | var user = await _userManager.FindByIdAsync(uid); 137 | var confirmResult = await _userManager.ConfirmEmailAsync(user, token); 138 | if (confirmResult.Succeeded) 139 | { 140 | return Redirect("/?confirmed=1"); 141 | } 142 | else 143 | { 144 | return Redirect("/error/email-confirm"); 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /api/Controllers/ContactController.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Collections.Generic; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Mvc; 5 | using aspnetCoreReactTemplate.Models; 6 | using Microsoft.EntityFrameworkCore; 7 | using System.Threading.Tasks; 8 | 9 | namespace aspnetCoreReactTemplate.Controllers 10 | { 11 | [Authorize] 12 | [Route("api/[controller]")] 13 | public class ContactsController : Controller 14 | { 15 | private readonly DefaultDbContext _context; 16 | 17 | public ContactsController(DefaultDbContext context) 18 | { 19 | _context = context; 20 | } 21 | 22 | // GET api/contacts 23 | [HttpGet] 24 | public IEnumerable Get() 25 | { 26 | return _context.Contacts.OrderBy((o)=> o.LastName); 27 | } 28 | 29 | // GET api/contacts/5 30 | [HttpGet("{id}", Name = "GetContact")] 31 | public Contact Get(int id) 32 | { 33 | return _context.Contacts.Find(id); 34 | } 35 | 36 | // GET api/contacts/?= 37 | [HttpGet("search")] 38 | public IEnumerable Search(string q) 39 | { 40 | return _context.Contacts. 41 | Where((c)=> c.LastName.ToLower().Contains(q.ToLower()) || c.FirstName.ToLower().Contains(q.ToLower())). 42 | OrderBy((o) => o.LastName); 43 | } 44 | 45 | // POST api/contacts 46 | [HttpPost] 47 | public async Task Post([FromBody]Contact model) 48 | { 49 | if (!ModelState.IsValid) 50 | { 51 | return BadRequest(ModelState); 52 | } 53 | 54 | _context.Contacts.Add(model); 55 | await _context.SaveChangesAsync(); 56 | return CreatedAtRoute("GetContact", new { id = model.Id }, model); 57 | } 58 | 59 | // PUT api/contacts/5 60 | [HttpPut("{id}")] 61 | public async Task Put(int id, [FromBody]Contact model) 62 | { 63 | if (!ModelState.IsValid) 64 | { 65 | return BadRequest(ModelState); 66 | } 67 | 68 | model.Id = id; 69 | _context.Update(model); 70 | await _context.SaveChangesAsync(); 71 | return Ok(); 72 | } 73 | 74 | // DELETE api/contacts/5 75 | [HttpDelete("{id}")] 76 | public async Task Delete(int id) 77 | { 78 | var contact = new Contact() { Id = id }; 79 | _context.Entry(contact).State = EntityState.Deleted; 80 | 81 | await _context.SaveChangesAsync(); 82 | return Ok(); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /api/Extensions/StringExtension.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace aspnetCoreReactTemplate.Extensions 4 | { 5 | public static class StringExtensions 6 | { 7 | public static string ToSnakeCase(this string input) 8 | { 9 | if (string.IsNullOrEmpty(input)) 10 | { 11 | return input; 12 | } 13 | 14 | var startUnderscores = Regex.Match(input, @"^_+"); 15 | return startUnderscores + Regex.Replace(input, @"([a-z0-9])([A-Z])", "$1_$2").ToLower(); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/Middleware/SpaFallbackMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace aspnetCoreReactTemplate 7 | { 8 | /* 9 | Middleware that will rewrite (not redirect!) nested SPA page requests to the SPA root path. 10 | For SPA apps that are using client-side routing, a refresh or direct request for a nested path will 11 | be received by the server but the root path page should be actually be served because the client 12 | is responsible for routing, not the server. Only those requests not prefixed with 13 | options.ApiPathPrefix and not containing a path extention (i.e. image.png, scripts.js) will 14 | be rewritten. 15 | 16 | (SpaFallbackOptions options): 17 | ApiPathPrefix - The api path prefix is what requests for the REST api begin with. These 18 | will be ignored and not rewritten. So, if this is supplied as 'api', 19 | any requests starting with 'api' will not be rewritten. 20 | RewritePath - What path to rewrite to (usually '/') 21 | 22 | Examples: 23 | (options.ApiPathPrefix == "api", options.RewritePath="/") 24 | http://localhost:5000/api/auth/login => (no rewrite) 25 | http://localhost:5000/style.css => (no rewrite) 26 | http://localhost:5000/contacts => / 27 | http://localhost:5000/contacts/5/edit => / 28 | */ 29 | 30 | 31 | public class SpaFallbackMiddleware 32 | { 33 | private readonly RequestDelegate _next; 34 | private readonly ILogger _logger; 35 | private SpaFallbackOptions _options; 36 | 37 | public SpaFallbackMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, SpaFallbackOptions options) 38 | { 39 | _next = next; 40 | _logger = loggerFactory.CreateLogger(); 41 | _options = options; 42 | } 43 | 44 | public async Task Invoke(HttpContext context) 45 | { 46 | _logger.LogInformation("Handling request: " + context.Request.Path); 47 | 48 | // If request path starts with _apiPathPrefix and the path does not have an extension (i.e. .css, .js, .png) 49 | if (!context.Request.Path.Value.StartsWith(_options.ApiPathPrefix) && !context.Request.Path.Value.Contains(".")) 50 | { 51 | _logger.LogInformation($"Rewriting path: {context.Request.Path} > {_options.RewritePath}"); 52 | context.Request.Path = _options.RewritePath; 53 | } 54 | 55 | await _next.Invoke(context); 56 | _logger.LogInformation("Finished handling request."); 57 | } 58 | } 59 | 60 | public static class SpaFallbackExtensions 61 | { 62 | public static IApplicationBuilder UseSpaFallback(this IApplicationBuilder builder, SpaFallbackOptions options) 63 | { 64 | if (options == null) 65 | { 66 | options = new SpaFallbackOptions(); 67 | } 68 | return builder.UseMiddleware(options); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /api/Middleware/SpaFallbackOptions.cs: -------------------------------------------------------------------------------- 1 | namespace aspnetCoreReactTemplate 2 | { 3 | public class SpaFallbackOptions 4 | { 5 | public SpaFallbackOptions() 6 | { 7 | this.ApiPathPrefix = "/api"; 8 | this.RewritePath = "/"; 9 | } 10 | public string ApiPathPrefix { get; set; } 11 | public string RewritePath { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /api/Migrations/20171204210645_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using aspnetCoreReactTemplate.Models; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Metadata; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage; 8 | using Microsoft.EntityFrameworkCore.Storage.Internal; 9 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 10 | using System; 11 | 12 | namespace api.Migrations 13 | { 14 | [DbContext(typeof(DefaultDbContext))] 15 | [Migration("20171204210645_Initial")] 16 | partial class Initial 17 | { 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder 22 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) 23 | .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); 24 | 25 | modelBuilder.Entity("aspnetCoreReactTemplate.Models.ApplicationUser", b => 26 | { 27 | b.Property("Id") 28 | .ValueGeneratedOnAdd() 29 | .HasColumnName("id"); 30 | 31 | b.Property("AccessFailedCount") 32 | .HasColumnName("access_failed_count"); 33 | 34 | b.Property("ConcurrencyStamp") 35 | .IsConcurrencyToken() 36 | .HasColumnName("concurrency_stamp"); 37 | 38 | b.Property("Email") 39 | .HasColumnName("email") 40 | .HasMaxLength(256); 41 | 42 | b.Property("EmailConfirmed") 43 | .HasColumnName("email_confirmed"); 44 | 45 | b.Property("GivenName") 46 | .HasColumnName("given_name"); 47 | 48 | b.Property("LockoutEnabled") 49 | .HasColumnName("lockout_enabled"); 50 | 51 | b.Property("LockoutEnd") 52 | .HasColumnName("lockout_end"); 53 | 54 | b.Property("NormalizedEmail") 55 | .HasColumnName("normalized_email") 56 | .HasMaxLength(256); 57 | 58 | b.Property("NormalizedUserName") 59 | .HasColumnName("normalized_user_name") 60 | .HasMaxLength(256); 61 | 62 | b.Property("PasswordHash") 63 | .HasColumnName("password_hash"); 64 | 65 | b.Property("PhoneNumber") 66 | .HasColumnName("phone_number"); 67 | 68 | b.Property("PhoneNumberConfirmed") 69 | .HasColumnName("phone_number_confirmed"); 70 | 71 | b.Property("SecurityStamp") 72 | .HasColumnName("security_stamp"); 73 | 74 | b.Property("TwoFactorEnabled") 75 | .HasColumnName("two_factor_enabled"); 76 | 77 | b.Property("UserName") 78 | .HasColumnName("user_name") 79 | .HasMaxLength(256); 80 | 81 | b.HasKey("Id") 82 | .HasName("pk_users"); 83 | 84 | b.HasIndex("NormalizedEmail") 85 | .HasName("email_index"); 86 | 87 | b.HasIndex("NormalizedUserName") 88 | .IsUnique() 89 | .HasName("user_name_index"); 90 | 91 | b.ToTable("users"); 92 | }); 93 | 94 | modelBuilder.Entity("aspnetCoreReactTemplate.Models.Contact", b => 95 | { 96 | b.Property("id") 97 | .ValueGeneratedOnAdd() 98 | .HasColumnName("id"); 99 | 100 | b.Property("email") 101 | .HasColumnName("email") 102 | .HasMaxLength(30); 103 | 104 | b.Property("firstName") 105 | .IsRequired() 106 | .HasColumnName("first_name"); 107 | 108 | b.Property("lastName") 109 | .IsRequired() 110 | .HasColumnName("last_name"); 111 | 112 | b.Property("phone") 113 | .HasColumnName("phone"); 114 | 115 | b.HasKey("id") 116 | .HasName("pk_contacts"); 117 | 118 | b.ToTable("contacts"); 119 | }); 120 | 121 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => 122 | { 123 | b.Property("Id") 124 | .ValueGeneratedOnAdd() 125 | .HasColumnName("id"); 126 | 127 | b.Property("ConcurrencyStamp") 128 | .IsConcurrencyToken() 129 | .HasColumnName("concurrency_stamp"); 130 | 131 | b.Property("Name") 132 | .HasColumnName("name") 133 | .HasMaxLength(256); 134 | 135 | b.Property("NormalizedName") 136 | .HasColumnName("normalized_name") 137 | .HasMaxLength(256); 138 | 139 | b.HasKey("Id") 140 | .HasName("pk_roles"); 141 | 142 | b.HasIndex("NormalizedName") 143 | .IsUnique() 144 | .HasName("role_name_index"); 145 | 146 | b.ToTable("roles"); 147 | }); 148 | 149 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 150 | { 151 | b.Property("Id") 152 | .ValueGeneratedOnAdd() 153 | .HasColumnName("id"); 154 | 155 | b.Property("ClaimType") 156 | .HasColumnName("claim_type"); 157 | 158 | b.Property("ClaimValue") 159 | .HasColumnName("claim_value"); 160 | 161 | b.Property("RoleId") 162 | .IsRequired() 163 | .HasColumnName("role_id"); 164 | 165 | b.HasKey("Id") 166 | .HasName("pk_role_claims"); 167 | 168 | b.HasIndex("RoleId") 169 | .HasName("ix_role_claims_role_id"); 170 | 171 | b.ToTable("role_claims"); 172 | }); 173 | 174 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 175 | { 176 | b.Property("Id") 177 | .ValueGeneratedOnAdd() 178 | .HasColumnName("id"); 179 | 180 | b.Property("ClaimType") 181 | .HasColumnName("claim_type"); 182 | 183 | b.Property("ClaimValue") 184 | .HasColumnName("claim_value"); 185 | 186 | b.Property("UserId") 187 | .IsRequired() 188 | .HasColumnName("user_id"); 189 | 190 | b.HasKey("Id") 191 | .HasName("pk_user_claims"); 192 | 193 | b.HasIndex("UserId") 194 | .HasName("ix_user_claims_user_id"); 195 | 196 | b.ToTable("user_claims"); 197 | }); 198 | 199 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 200 | { 201 | b.Property("LoginProvider") 202 | .HasColumnName("login_provider"); 203 | 204 | b.Property("ProviderKey") 205 | .HasColumnName("provider_key"); 206 | 207 | b.Property("ProviderDisplayName") 208 | .HasColumnName("provider_display_name"); 209 | 210 | b.Property("UserId") 211 | .IsRequired() 212 | .HasColumnName("user_id"); 213 | 214 | b.HasKey("LoginProvider", "ProviderKey") 215 | .HasName("pk_user_logins"); 216 | 217 | b.HasIndex("UserId") 218 | .HasName("ix_user_logins_user_id"); 219 | 220 | b.ToTable("user_logins"); 221 | }); 222 | 223 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 224 | { 225 | b.Property("UserId") 226 | .HasColumnName("user_id"); 227 | 228 | b.Property("RoleId") 229 | .HasColumnName("role_id"); 230 | 231 | b.HasKey("UserId", "RoleId") 232 | .HasName("pk_user_roles"); 233 | 234 | b.HasIndex("RoleId") 235 | .HasName("ix_user_roles_role_id"); 236 | 237 | b.ToTable("user_roles"); 238 | }); 239 | 240 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 241 | { 242 | b.Property("UserId") 243 | .HasColumnName("user_id"); 244 | 245 | b.Property("LoginProvider") 246 | .HasColumnName("login_provider"); 247 | 248 | b.Property("Name") 249 | .HasColumnName("name"); 250 | 251 | b.Property("Value") 252 | .HasColumnName("value"); 253 | 254 | b.HasKey("UserId", "LoginProvider", "Name") 255 | .HasName("pk_user_tokens"); 256 | 257 | b.ToTable("user_tokens"); 258 | }); 259 | 260 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 261 | { 262 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") 263 | .WithMany() 264 | .HasForeignKey("RoleId") 265 | .HasConstraintName("fk_role_claims_roles_role_id") 266 | .OnDelete(DeleteBehavior.Cascade); 267 | }); 268 | 269 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 270 | { 271 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser") 272 | .WithMany() 273 | .HasForeignKey("UserId") 274 | .HasConstraintName("fk_user_claims_users_user_id") 275 | .OnDelete(DeleteBehavior.Cascade); 276 | }); 277 | 278 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 279 | { 280 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser") 281 | .WithMany() 282 | .HasForeignKey("UserId") 283 | .HasConstraintName("fk_user_logins_users_user_id") 284 | .OnDelete(DeleteBehavior.Cascade); 285 | }); 286 | 287 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 288 | { 289 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") 290 | .WithMany() 291 | .HasForeignKey("RoleId") 292 | .HasConstraintName("fk_user_roles_roles_role_id") 293 | .OnDelete(DeleteBehavior.Cascade); 294 | 295 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser") 296 | .WithMany() 297 | .HasForeignKey("UserId") 298 | .HasConstraintName("fk_user_roles_users_user_id") 299 | .OnDelete(DeleteBehavior.Cascade); 300 | }); 301 | 302 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 303 | { 304 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser") 305 | .WithMany() 306 | .HasForeignKey("UserId") 307 | .HasConstraintName("fk_user_tokens_users_user_id") 308 | .OnDelete(DeleteBehavior.Cascade); 309 | }); 310 | #pragma warning restore 612, 618 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /api/Migrations/20171204210645_Initial.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Metadata; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 4 | using System; 5 | using System.Collections.Generic; 6 | 7 | namespace api.Migrations 8 | { 9 | public partial class Initial : Migration 10 | { 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.CreateTable( 14 | name: "contacts", 15 | columns: table => new 16 | { 17 | id = table.Column(type: "int4", nullable: false) 18 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn), 19 | email = table.Column(type: "varchar(30)", maxLength: 30, nullable: true), 20 | first_name = table.Column(type: "text", nullable: false), 21 | last_name = table.Column(type: "text", nullable: false), 22 | phone = table.Column(type: "text", nullable: true) 23 | }, 24 | constraints: table => 25 | { 26 | table.PrimaryKey("pk_contacts", x => x.id); 27 | }); 28 | 29 | migrationBuilder.CreateTable( 30 | name: "roles", 31 | columns: table => new 32 | { 33 | id = table.Column(type: "text", nullable: false), 34 | concurrency_stamp = table.Column(type: "text", nullable: true), 35 | name = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), 36 | normalized_name = table.Column(type: "varchar(256)", maxLength: 256, nullable: true) 37 | }, 38 | constraints: table => 39 | { 40 | table.PrimaryKey("pk_roles", x => x.id); 41 | }); 42 | 43 | migrationBuilder.CreateTable( 44 | name: "users", 45 | columns: table => new 46 | { 47 | id = table.Column(type: "text", nullable: false), 48 | access_failed_count = table.Column(type: "int4", nullable: false), 49 | concurrency_stamp = table.Column(type: "text", nullable: true), 50 | email = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), 51 | email_confirmed = table.Column(type: "bool", nullable: false), 52 | given_name = table.Column(type: "text", nullable: true), 53 | lockout_enabled = table.Column(type: "bool", nullable: false), 54 | lockout_end = table.Column(type: "timestamptz", nullable: true), 55 | normalized_email = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), 56 | normalized_user_name = table.Column(type: "varchar(256)", maxLength: 256, nullable: true), 57 | password_hash = table.Column(type: "text", nullable: true), 58 | phone_number = table.Column(type: "text", nullable: true), 59 | phone_number_confirmed = table.Column(type: "bool", nullable: false), 60 | security_stamp = table.Column(type: "text", nullable: true), 61 | two_factor_enabled = table.Column(type: "bool", nullable: false), 62 | user_name = table.Column(type: "varchar(256)", maxLength: 256, nullable: true) 63 | }, 64 | constraints: table => 65 | { 66 | table.PrimaryKey("pk_users", x => x.id); 67 | }); 68 | 69 | migrationBuilder.CreateTable( 70 | name: "role_claims", 71 | columns: table => new 72 | { 73 | id = table.Column(type: "int4", nullable: false) 74 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn), 75 | claim_type = table.Column(type: "text", nullable: true), 76 | claim_value = table.Column(type: "text", nullable: true), 77 | role_id = table.Column(type: "text", nullable: false) 78 | }, 79 | constraints: table => 80 | { 81 | table.PrimaryKey("pk_role_claims", x => x.id); 82 | table.ForeignKey( 83 | name: "fk_role_claims_roles_role_id", 84 | column: x => x.role_id, 85 | principalTable: "roles", 86 | principalColumn: "id", 87 | onDelete: ReferentialAction.Cascade); 88 | }); 89 | 90 | migrationBuilder.CreateTable( 91 | name: "user_claims", 92 | columns: table => new 93 | { 94 | id = table.Column(type: "int4", nullable: false) 95 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn), 96 | claim_type = table.Column(type: "text", nullable: true), 97 | claim_value = table.Column(type: "text", nullable: true), 98 | user_id = table.Column(type: "text", nullable: false) 99 | }, 100 | constraints: table => 101 | { 102 | table.PrimaryKey("pk_user_claims", x => x.id); 103 | table.ForeignKey( 104 | name: "fk_user_claims_users_user_id", 105 | column: x => x.user_id, 106 | principalTable: "users", 107 | principalColumn: "id", 108 | onDelete: ReferentialAction.Cascade); 109 | }); 110 | 111 | migrationBuilder.CreateTable( 112 | name: "user_logins", 113 | columns: table => new 114 | { 115 | login_provider = table.Column(type: "text", nullable: false), 116 | provider_key = table.Column(type: "text", nullable: false), 117 | provider_display_name = table.Column(type: "text", nullable: true), 118 | user_id = table.Column(type: "text", nullable: false) 119 | }, 120 | constraints: table => 121 | { 122 | table.PrimaryKey("pk_user_logins", x => new { x.login_provider, x.provider_key }); 123 | table.ForeignKey( 124 | name: "fk_user_logins_users_user_id", 125 | column: x => x.user_id, 126 | principalTable: "users", 127 | principalColumn: "id", 128 | onDelete: ReferentialAction.Cascade); 129 | }); 130 | 131 | migrationBuilder.CreateTable( 132 | name: "user_roles", 133 | columns: table => new 134 | { 135 | user_id = table.Column(type: "text", nullable: false), 136 | role_id = table.Column(type: "text", nullable: false) 137 | }, 138 | constraints: table => 139 | { 140 | table.PrimaryKey("pk_user_roles", x => new { x.user_id, x.role_id }); 141 | table.ForeignKey( 142 | name: "fk_user_roles_roles_role_id", 143 | column: x => x.role_id, 144 | principalTable: "roles", 145 | principalColumn: "id", 146 | onDelete: ReferentialAction.Cascade); 147 | table.ForeignKey( 148 | name: "fk_user_roles_users_user_id", 149 | column: x => x.user_id, 150 | principalTable: "users", 151 | principalColumn: "id", 152 | onDelete: ReferentialAction.Cascade); 153 | }); 154 | 155 | migrationBuilder.CreateTable( 156 | name: "user_tokens", 157 | columns: table => new 158 | { 159 | user_id = table.Column(type: "text", nullable: false), 160 | login_provider = table.Column(type: "text", nullable: false), 161 | name = table.Column(type: "text", nullable: false), 162 | value = table.Column(type: "text", nullable: true) 163 | }, 164 | constraints: table => 165 | { 166 | table.PrimaryKey("pk_user_tokens", x => new { x.user_id, x.login_provider, x.name }); 167 | table.ForeignKey( 168 | name: "fk_user_tokens_users_user_id", 169 | column: x => x.user_id, 170 | principalTable: "users", 171 | principalColumn: "id", 172 | onDelete: ReferentialAction.Cascade); 173 | }); 174 | 175 | migrationBuilder.CreateIndex( 176 | name: "ix_role_claims_role_id", 177 | table: "role_claims", 178 | column: "role_id"); 179 | 180 | migrationBuilder.CreateIndex( 181 | name: "role_name_index", 182 | table: "roles", 183 | column: "normalized_name", 184 | unique: true); 185 | 186 | migrationBuilder.CreateIndex( 187 | name: "ix_user_claims_user_id", 188 | table: "user_claims", 189 | column: "user_id"); 190 | 191 | migrationBuilder.CreateIndex( 192 | name: "ix_user_logins_user_id", 193 | table: "user_logins", 194 | column: "user_id"); 195 | 196 | migrationBuilder.CreateIndex( 197 | name: "ix_user_roles_role_id", 198 | table: "user_roles", 199 | column: "role_id"); 200 | 201 | migrationBuilder.CreateIndex( 202 | name: "email_index", 203 | table: "users", 204 | column: "normalized_email"); 205 | 206 | migrationBuilder.CreateIndex( 207 | name: "user_name_index", 208 | table: "users", 209 | column: "normalized_user_name", 210 | unique: true); 211 | } 212 | 213 | protected override void Down(MigrationBuilder migrationBuilder) 214 | { 215 | migrationBuilder.DropTable( 216 | name: "contacts"); 217 | 218 | migrationBuilder.DropTable( 219 | name: "role_claims"); 220 | 221 | migrationBuilder.DropTable( 222 | name: "user_claims"); 223 | 224 | migrationBuilder.DropTable( 225 | name: "user_logins"); 226 | 227 | migrationBuilder.DropTable( 228 | name: "user_roles"); 229 | 230 | migrationBuilder.DropTable( 231 | name: "user_tokens"); 232 | 233 | migrationBuilder.DropTable( 234 | name: "roles"); 235 | 236 | migrationBuilder.DropTable( 237 | name: "users"); 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /api/Migrations/20200128145031_1580223026.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 aspnetCoreReactTemplate.Models; 9 | 10 | namespace api.Migrations 11 | { 12 | [DbContext(typeof(DefaultDbContext))] 13 | [Migration("20200128145031_1580223026")] 14 | partial class _1580223026 15 | { 16 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) 21 | .HasAnnotation("ProductVersion", "3.1.1") 22 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 23 | 24 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => 25 | { 26 | b.Property("Id") 27 | .HasColumnName("id") 28 | .HasColumnType("text"); 29 | 30 | b.Property("ConcurrencyStamp") 31 | .IsConcurrencyToken() 32 | .HasColumnName("concurrency_stamp") 33 | .HasColumnType("text"); 34 | 35 | b.Property("Name") 36 | .HasColumnName("name") 37 | .HasColumnType("character varying(256)") 38 | .HasMaxLength(256); 39 | 40 | b.Property("NormalizedName") 41 | .HasColumnName("normalized_name") 42 | .HasColumnType("character varying(256)") 43 | .HasMaxLength(256); 44 | 45 | b.HasKey("Id") 46 | .HasName("pk_roles"); 47 | 48 | b.HasIndex("NormalizedName") 49 | .IsUnique() 50 | .HasName("role_name_index"); 51 | 52 | b.ToTable("roles"); 53 | }); 54 | 55 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 56 | { 57 | b.Property("Id") 58 | .ValueGeneratedOnAdd() 59 | .HasColumnName("id") 60 | .HasColumnType("integer") 61 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 62 | 63 | b.Property("ClaimType") 64 | .HasColumnName("claim_type") 65 | .HasColumnType("text"); 66 | 67 | b.Property("ClaimValue") 68 | .HasColumnName("claim_value") 69 | .HasColumnType("text"); 70 | 71 | b.Property("RoleId") 72 | .IsRequired() 73 | .HasColumnName("role_id") 74 | .HasColumnType("text"); 75 | 76 | b.HasKey("Id") 77 | .HasName("pk_role_claims"); 78 | 79 | b.HasIndex("RoleId") 80 | .HasName("ix_role_claims_role_id"); 81 | 82 | b.ToTable("role_claims"); 83 | }); 84 | 85 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 86 | { 87 | b.Property("Id") 88 | .ValueGeneratedOnAdd() 89 | .HasColumnName("id") 90 | .HasColumnType("integer") 91 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 92 | 93 | b.Property("ClaimType") 94 | .HasColumnName("claim_type") 95 | .HasColumnType("text"); 96 | 97 | b.Property("ClaimValue") 98 | .HasColumnName("claim_value") 99 | .HasColumnType("text"); 100 | 101 | b.Property("UserId") 102 | .IsRequired() 103 | .HasColumnName("user_id") 104 | .HasColumnType("text"); 105 | 106 | b.HasKey("Id") 107 | .HasName("pk_user_claims"); 108 | 109 | b.HasIndex("UserId") 110 | .HasName("ix_user_claims_user_id"); 111 | 112 | b.ToTable("user_claims"); 113 | }); 114 | 115 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 116 | { 117 | b.Property("LoginProvider") 118 | .HasColumnName("login_provider") 119 | .HasColumnType("text"); 120 | 121 | b.Property("ProviderKey") 122 | .HasColumnName("provider_key") 123 | .HasColumnType("text"); 124 | 125 | b.Property("ProviderDisplayName") 126 | .HasColumnName("provider_display_name") 127 | .HasColumnType("text"); 128 | 129 | b.Property("UserId") 130 | .IsRequired() 131 | .HasColumnName("user_id") 132 | .HasColumnType("text"); 133 | 134 | b.HasKey("LoginProvider", "ProviderKey") 135 | .HasName("pk_user_logins"); 136 | 137 | b.HasIndex("UserId") 138 | .HasName("ix_user_logins_user_id"); 139 | 140 | b.ToTable("user_logins"); 141 | }); 142 | 143 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 144 | { 145 | b.Property("UserId") 146 | .HasColumnName("user_id") 147 | .HasColumnType("text"); 148 | 149 | b.Property("RoleId") 150 | .HasColumnName("role_id") 151 | .HasColumnType("text"); 152 | 153 | b.HasKey("UserId", "RoleId") 154 | .HasName("pk_user_roles"); 155 | 156 | b.HasIndex("RoleId") 157 | .HasName("ix_user_roles_role_id"); 158 | 159 | b.ToTable("user_roles"); 160 | }); 161 | 162 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 163 | { 164 | b.Property("UserId") 165 | .HasColumnName("user_id") 166 | .HasColumnType("text"); 167 | 168 | b.Property("LoginProvider") 169 | .HasColumnName("login_provider") 170 | .HasColumnType("text"); 171 | 172 | b.Property("Name") 173 | .HasColumnName("name") 174 | .HasColumnType("text"); 175 | 176 | b.Property("Value") 177 | .HasColumnName("value") 178 | .HasColumnType("text"); 179 | 180 | b.HasKey("UserId", "LoginProvider", "Name") 181 | .HasName("pk_user_tokens"); 182 | 183 | b.ToTable("user_tokens"); 184 | }); 185 | 186 | modelBuilder.Entity("aspnetCoreReactTemplate.Models.ApplicationUser", b => 187 | { 188 | b.Property("Id") 189 | .HasColumnName("id") 190 | .HasColumnType("text"); 191 | 192 | b.Property("AccessFailedCount") 193 | .HasColumnName("access_failed_count") 194 | .HasColumnType("integer"); 195 | 196 | b.Property("ConcurrencyStamp") 197 | .IsConcurrencyToken() 198 | .HasColumnName("concurrency_stamp") 199 | .HasColumnType("text"); 200 | 201 | b.Property("Email") 202 | .HasColumnName("email") 203 | .HasColumnType("character varying(256)") 204 | .HasMaxLength(256); 205 | 206 | b.Property("EmailConfirmed") 207 | .HasColumnName("email_confirmed") 208 | .HasColumnType("boolean"); 209 | 210 | b.Property("GivenName") 211 | .HasColumnName("given_name") 212 | .HasColumnType("text"); 213 | 214 | b.Property("LockoutEnabled") 215 | .HasColumnName("lockout_enabled") 216 | .HasColumnType("boolean"); 217 | 218 | b.Property("LockoutEnd") 219 | .HasColumnName("lockout_end") 220 | .HasColumnType("timestamp with time zone"); 221 | 222 | b.Property("NormalizedEmail") 223 | .HasColumnName("normalized_email") 224 | .HasColumnType("character varying(256)") 225 | .HasMaxLength(256); 226 | 227 | b.Property("NormalizedUserName") 228 | .HasColumnName("normalized_user_name") 229 | .HasColumnType("character varying(256)") 230 | .HasMaxLength(256); 231 | 232 | b.Property("PasswordHash") 233 | .HasColumnName("password_hash") 234 | .HasColumnType("text"); 235 | 236 | b.Property("PhoneNumber") 237 | .HasColumnName("phone_number") 238 | .HasColumnType("text"); 239 | 240 | b.Property("PhoneNumberConfirmed") 241 | .HasColumnName("phone_number_confirmed") 242 | .HasColumnType("boolean"); 243 | 244 | b.Property("SecurityStamp") 245 | .HasColumnName("security_stamp") 246 | .HasColumnType("text"); 247 | 248 | b.Property("TwoFactorEnabled") 249 | .HasColumnName("two_factor_enabled") 250 | .HasColumnType("boolean"); 251 | 252 | b.Property("UserName") 253 | .HasColumnName("user_name") 254 | .HasColumnType("character varying(256)") 255 | .HasMaxLength(256); 256 | 257 | b.HasKey("Id") 258 | .HasName("pk_users"); 259 | 260 | b.HasIndex("NormalizedEmail") 261 | .HasName("email_index"); 262 | 263 | b.HasIndex("NormalizedUserName") 264 | .IsUnique() 265 | .HasName("user_name_index"); 266 | 267 | b.ToTable("users"); 268 | }); 269 | 270 | modelBuilder.Entity("aspnetCoreReactTemplate.Models.Contact", b => 271 | { 272 | b.Property("Id") 273 | .ValueGeneratedOnAdd() 274 | .HasColumnName("id") 275 | .HasColumnType("integer") 276 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 277 | 278 | b.Property("Email") 279 | .HasColumnName("email") 280 | .HasColumnType("character varying(30)") 281 | .HasMaxLength(30); 282 | 283 | b.Property("FirstName") 284 | .IsRequired() 285 | .HasColumnName("first_name") 286 | .HasColumnType("text"); 287 | 288 | b.Property("LastName") 289 | .IsRequired() 290 | .HasColumnName("last_name") 291 | .HasColumnType("text"); 292 | 293 | b.Property("Phone") 294 | .HasColumnName("phone") 295 | .HasColumnType("text"); 296 | 297 | b.HasKey("Id") 298 | .HasName("pk_contacts"); 299 | 300 | b.ToTable("contacts"); 301 | }); 302 | 303 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 304 | { 305 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 306 | .WithMany() 307 | .HasForeignKey("RoleId") 308 | .HasConstraintName("fk_role_claims_roles_role_id") 309 | .OnDelete(DeleteBehavior.Cascade) 310 | .IsRequired(); 311 | }); 312 | 313 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 314 | { 315 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser", null) 316 | .WithMany() 317 | .HasForeignKey("UserId") 318 | .HasConstraintName("fk_user_claims_asp_net_users_user_id") 319 | .OnDelete(DeleteBehavior.Cascade) 320 | .IsRequired(); 321 | }); 322 | 323 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 324 | { 325 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser", null) 326 | .WithMany() 327 | .HasForeignKey("UserId") 328 | .HasConstraintName("fk_user_logins_asp_net_users_user_id") 329 | .OnDelete(DeleteBehavior.Cascade) 330 | .IsRequired(); 331 | }); 332 | 333 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 334 | { 335 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 336 | .WithMany() 337 | .HasForeignKey("RoleId") 338 | .HasConstraintName("fk_user_roles_roles_role_id") 339 | .OnDelete(DeleteBehavior.Cascade) 340 | .IsRequired(); 341 | 342 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser", null) 343 | .WithMany() 344 | .HasForeignKey("UserId") 345 | .HasConstraintName("fk_user_roles_asp_net_users_user_id") 346 | .OnDelete(DeleteBehavior.Cascade) 347 | .IsRequired(); 348 | }); 349 | 350 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 351 | { 352 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser", null) 353 | .WithMany() 354 | .HasForeignKey("UserId") 355 | .HasConstraintName("fk_user_tokens_asp_net_users_user_id") 356 | .OnDelete(DeleteBehavior.Cascade) 357 | .IsRequired(); 358 | }); 359 | #pragma warning restore 612, 618 360 | } 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /api/Migrations/20200128145031_1580223026.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | namespace api.Migrations 4 | { 5 | public partial class _1580223026 : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | 10 | } 11 | 12 | protected override void Down(MigrationBuilder migrationBuilder) 13 | { 14 | 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/Migrations/DefaultDbContextModelSnapshot.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 aspnetCoreReactTemplate.Models; 8 | 9 | namespace api.Migrations 10 | { 11 | [DbContext(typeof(DefaultDbContext))] 12 | partial class DefaultDbContextModelSnapshot : ModelSnapshot 13 | { 14 | protected override void BuildModel(ModelBuilder modelBuilder) 15 | { 16 | #pragma warning disable 612, 618 17 | modelBuilder 18 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) 19 | .HasAnnotation("ProductVersion", "3.1.1") 20 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 21 | 22 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => 23 | { 24 | b.Property("Id") 25 | .HasColumnName("id") 26 | .HasColumnType("text"); 27 | 28 | b.Property("ConcurrencyStamp") 29 | .IsConcurrencyToken() 30 | .HasColumnName("concurrency_stamp") 31 | .HasColumnType("text"); 32 | 33 | b.Property("Name") 34 | .HasColumnName("name") 35 | .HasColumnType("character varying(256)") 36 | .HasMaxLength(256); 37 | 38 | b.Property("NormalizedName") 39 | .HasColumnName("normalized_name") 40 | .HasColumnType("character varying(256)") 41 | .HasMaxLength(256); 42 | 43 | b.HasKey("Id") 44 | .HasName("pk_roles"); 45 | 46 | b.HasIndex("NormalizedName") 47 | .IsUnique() 48 | .HasName("role_name_index"); 49 | 50 | b.ToTable("roles"); 51 | }); 52 | 53 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 54 | { 55 | b.Property("Id") 56 | .ValueGeneratedOnAdd() 57 | .HasColumnName("id") 58 | .HasColumnType("integer") 59 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 60 | 61 | b.Property("ClaimType") 62 | .HasColumnName("claim_type") 63 | .HasColumnType("text"); 64 | 65 | b.Property("ClaimValue") 66 | .HasColumnName("claim_value") 67 | .HasColumnType("text"); 68 | 69 | b.Property("RoleId") 70 | .IsRequired() 71 | .HasColumnName("role_id") 72 | .HasColumnType("text"); 73 | 74 | b.HasKey("Id") 75 | .HasName("pk_role_claims"); 76 | 77 | b.HasIndex("RoleId") 78 | .HasName("ix_role_claims_role_id"); 79 | 80 | b.ToTable("role_claims"); 81 | }); 82 | 83 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 84 | { 85 | b.Property("Id") 86 | .ValueGeneratedOnAdd() 87 | .HasColumnName("id") 88 | .HasColumnType("integer") 89 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 90 | 91 | b.Property("ClaimType") 92 | .HasColumnName("claim_type") 93 | .HasColumnType("text"); 94 | 95 | b.Property("ClaimValue") 96 | .HasColumnName("claim_value") 97 | .HasColumnType("text"); 98 | 99 | b.Property("UserId") 100 | .IsRequired() 101 | .HasColumnName("user_id") 102 | .HasColumnType("text"); 103 | 104 | b.HasKey("Id") 105 | .HasName("pk_user_claims"); 106 | 107 | b.HasIndex("UserId") 108 | .HasName("ix_user_claims_user_id"); 109 | 110 | b.ToTable("user_claims"); 111 | }); 112 | 113 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 114 | { 115 | b.Property("LoginProvider") 116 | .HasColumnName("login_provider") 117 | .HasColumnType("text"); 118 | 119 | b.Property("ProviderKey") 120 | .HasColumnName("provider_key") 121 | .HasColumnType("text"); 122 | 123 | b.Property("ProviderDisplayName") 124 | .HasColumnName("provider_display_name") 125 | .HasColumnType("text"); 126 | 127 | b.Property("UserId") 128 | .IsRequired() 129 | .HasColumnName("user_id") 130 | .HasColumnType("text"); 131 | 132 | b.HasKey("LoginProvider", "ProviderKey") 133 | .HasName("pk_user_logins"); 134 | 135 | b.HasIndex("UserId") 136 | .HasName("ix_user_logins_user_id"); 137 | 138 | b.ToTable("user_logins"); 139 | }); 140 | 141 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 142 | { 143 | b.Property("UserId") 144 | .HasColumnName("user_id") 145 | .HasColumnType("text"); 146 | 147 | b.Property("RoleId") 148 | .HasColumnName("role_id") 149 | .HasColumnType("text"); 150 | 151 | b.HasKey("UserId", "RoleId") 152 | .HasName("pk_user_roles"); 153 | 154 | b.HasIndex("RoleId") 155 | .HasName("ix_user_roles_role_id"); 156 | 157 | b.ToTable("user_roles"); 158 | }); 159 | 160 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 161 | { 162 | b.Property("UserId") 163 | .HasColumnName("user_id") 164 | .HasColumnType("text"); 165 | 166 | b.Property("LoginProvider") 167 | .HasColumnName("login_provider") 168 | .HasColumnType("text"); 169 | 170 | b.Property("Name") 171 | .HasColumnName("name") 172 | .HasColumnType("text"); 173 | 174 | b.Property("Value") 175 | .HasColumnName("value") 176 | .HasColumnType("text"); 177 | 178 | b.HasKey("UserId", "LoginProvider", "Name") 179 | .HasName("pk_user_tokens"); 180 | 181 | b.ToTable("user_tokens"); 182 | }); 183 | 184 | modelBuilder.Entity("aspnetCoreReactTemplate.Models.ApplicationUser", b => 185 | { 186 | b.Property("Id") 187 | .HasColumnName("id") 188 | .HasColumnType("text"); 189 | 190 | b.Property("AccessFailedCount") 191 | .HasColumnName("access_failed_count") 192 | .HasColumnType("integer"); 193 | 194 | b.Property("ConcurrencyStamp") 195 | .IsConcurrencyToken() 196 | .HasColumnName("concurrency_stamp") 197 | .HasColumnType("text"); 198 | 199 | b.Property("Email") 200 | .HasColumnName("email") 201 | .HasColumnType("character varying(256)") 202 | .HasMaxLength(256); 203 | 204 | b.Property("EmailConfirmed") 205 | .HasColumnName("email_confirmed") 206 | .HasColumnType("boolean"); 207 | 208 | b.Property("GivenName") 209 | .HasColumnName("given_name") 210 | .HasColumnType("text"); 211 | 212 | b.Property("LockoutEnabled") 213 | .HasColumnName("lockout_enabled") 214 | .HasColumnType("boolean"); 215 | 216 | b.Property("LockoutEnd") 217 | .HasColumnName("lockout_end") 218 | .HasColumnType("timestamp with time zone"); 219 | 220 | b.Property("NormalizedEmail") 221 | .HasColumnName("normalized_email") 222 | .HasColumnType("character varying(256)") 223 | .HasMaxLength(256); 224 | 225 | b.Property("NormalizedUserName") 226 | .HasColumnName("normalized_user_name") 227 | .HasColumnType("character varying(256)") 228 | .HasMaxLength(256); 229 | 230 | b.Property("PasswordHash") 231 | .HasColumnName("password_hash") 232 | .HasColumnType("text"); 233 | 234 | b.Property("PhoneNumber") 235 | .HasColumnName("phone_number") 236 | .HasColumnType("text"); 237 | 238 | b.Property("PhoneNumberConfirmed") 239 | .HasColumnName("phone_number_confirmed") 240 | .HasColumnType("boolean"); 241 | 242 | b.Property("SecurityStamp") 243 | .HasColumnName("security_stamp") 244 | .HasColumnType("text"); 245 | 246 | b.Property("TwoFactorEnabled") 247 | .HasColumnName("two_factor_enabled") 248 | .HasColumnType("boolean"); 249 | 250 | b.Property("UserName") 251 | .HasColumnName("user_name") 252 | .HasColumnType("character varying(256)") 253 | .HasMaxLength(256); 254 | 255 | b.HasKey("Id") 256 | .HasName("pk_users"); 257 | 258 | b.HasIndex("NormalizedEmail") 259 | .HasName("email_index"); 260 | 261 | b.HasIndex("NormalizedUserName") 262 | .IsUnique() 263 | .HasName("user_name_index"); 264 | 265 | b.ToTable("users"); 266 | }); 267 | 268 | modelBuilder.Entity("aspnetCoreReactTemplate.Models.Contact", b => 269 | { 270 | b.Property("Id") 271 | .ValueGeneratedOnAdd() 272 | .HasColumnName("id") 273 | .HasColumnType("integer") 274 | .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); 275 | 276 | b.Property("Email") 277 | .HasColumnName("email") 278 | .HasColumnType("character varying(30)") 279 | .HasMaxLength(30); 280 | 281 | b.Property("FirstName") 282 | .IsRequired() 283 | .HasColumnName("first_name") 284 | .HasColumnType("text"); 285 | 286 | b.Property("LastName") 287 | .IsRequired() 288 | .HasColumnName("last_name") 289 | .HasColumnType("text"); 290 | 291 | b.Property("Phone") 292 | .HasColumnName("phone") 293 | .HasColumnType("text"); 294 | 295 | b.HasKey("Id") 296 | .HasName("pk_contacts"); 297 | 298 | b.ToTable("contacts"); 299 | }); 300 | 301 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 302 | { 303 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 304 | .WithMany() 305 | .HasForeignKey("RoleId") 306 | .HasConstraintName("fk_role_claims_roles_role_id") 307 | .OnDelete(DeleteBehavior.Cascade) 308 | .IsRequired(); 309 | }); 310 | 311 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 312 | { 313 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser", null) 314 | .WithMany() 315 | .HasForeignKey("UserId") 316 | .HasConstraintName("fk_user_claims_asp_net_users_user_id") 317 | .OnDelete(DeleteBehavior.Cascade) 318 | .IsRequired(); 319 | }); 320 | 321 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 322 | { 323 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser", null) 324 | .WithMany() 325 | .HasForeignKey("UserId") 326 | .HasConstraintName("fk_user_logins_asp_net_users_user_id") 327 | .OnDelete(DeleteBehavior.Cascade) 328 | .IsRequired(); 329 | }); 330 | 331 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 332 | { 333 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 334 | .WithMany() 335 | .HasForeignKey("RoleId") 336 | .HasConstraintName("fk_user_roles_roles_role_id") 337 | .OnDelete(DeleteBehavior.Cascade) 338 | .IsRequired(); 339 | 340 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser", null) 341 | .WithMany() 342 | .HasForeignKey("UserId") 343 | .HasConstraintName("fk_user_roles_asp_net_users_user_id") 344 | .OnDelete(DeleteBehavior.Cascade) 345 | .IsRequired(); 346 | }); 347 | 348 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 349 | { 350 | b.HasOne("aspnetCoreReactTemplate.Models.ApplicationUser", null) 351 | .WithMany() 352 | .HasForeignKey("UserId") 353 | .HasConstraintName("fk_user_tokens_asp_net_users_user_id") 354 | .OnDelete(DeleteBehavior.Cascade) 355 | .IsRequired(); 356 | }); 357 | #pragma warning restore 612, 618 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /api/Models/ApplicationUser.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 3 | 4 | namespace aspnetCoreReactTemplate.Models 5 | { 6 | public class ApplicationUser: IdentityUser 7 | { 8 | public string GivenName { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /api/Models/Contact.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace aspnetCoreReactTemplate.Models 4 | { 5 | public class Contact 6 | { 7 | public int Id { get; set; } 8 | 9 | [Required] 10 | [MinLength(3)] 11 | public string LastName { get; set; } 12 | 13 | [Required] 14 | public string FirstName { get; set; } 15 | 16 | public string Phone { get; set; } 17 | 18 | [DataType(DataType.EmailAddress)] 19 | [StringLength(30, MinimumLength = 0)] 20 | public string Email { get; set; } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/Models/DefaultDbContext.cs: -------------------------------------------------------------------------------- 1 | using aspnetCoreReactTemplate.Extensions; 2 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace aspnetCoreReactTemplate.Models 6 | { 7 | public class DefaultDbContext : IdentityDbContext 8 | { 9 | public DefaultDbContext(DbContextOptions options) 10 | : base(options) 11 | { 12 | } 13 | 14 | public DbSet ApplicationUsers { get; set; } 15 | public DbSet Contacts { get; set; } 16 | 17 | protected override void OnModelCreating(ModelBuilder modelBuilder) 18 | { 19 | base.OnModelCreating(modelBuilder); 20 | 21 | foreach (var entity in modelBuilder.Model.GetEntityTypes()) 22 | { 23 | // Remove 'AspNet' prefix and convert table name from PascalCase to snake_case. E.g. AspNetRoleClaims -> role_claims 24 | entity.SetTableName(entity.GetTableName().Replace("AspNet", "").ToSnakeCase()); 25 | 26 | // Convert column names from PascalCase to snake_case. 27 | foreach (var property in entity.GetProperties()) 28 | { 29 | property.SetColumnName(property.Name.ToSnakeCase()); 30 | } 31 | 32 | // Convert primary key names from PascalCase to snake_case. E.g. PK_users -> pk_users 33 | foreach (var key in entity.GetKeys()) 34 | { 35 | key.SetName(key.GetName().ToSnakeCase()); 36 | } 37 | 38 | // Convert foreign key names from PascalCase to snake_case. 39 | foreach (var key in entity.GetForeignKeys()) 40 | { 41 | key.SetConstraintName(key.GetConstraintName().ToSnakeCase()); 42 | } 43 | 44 | // Convert index names from PascalCase to snake_case. 45 | foreach (var index in entity.GetIndexes()) 46 | { 47 | index.SetName(index.GetName().ToSnakeCase()); 48 | } 49 | } 50 | 51 | } 52 | 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /api/Models/DefaultDbContextInitializer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | 5 | namespace aspnetCoreReactTemplate.Models 6 | { 7 | public class DefaultDbContextInitializer : IDefaultDbContextInitializer 8 | { 9 | private readonly DefaultDbContext _context; 10 | private readonly UserManager _userManager; 11 | private readonly RoleManager _roleManager; 12 | 13 | public DefaultDbContextInitializer(DefaultDbContext context, UserManager userManager, RoleManager roleManager) 14 | { 15 | _userManager = userManager; 16 | _context = context; 17 | _roleManager = roleManager; 18 | } 19 | 20 | public bool EnsureCreated() 21 | { 22 | return _context.Database.EnsureCreated(); 23 | } 24 | 25 | public async Task Seed() 26 | { 27 | var email = "user@test.com"; 28 | if (await _userManager.FindByEmailAsync(email) == null) 29 | { 30 | var user = new ApplicationUser 31 | { 32 | UserName = email, 33 | Email = email, 34 | EmailConfirmed = true, 35 | GivenName = "John Doe" 36 | }; 37 | 38 | await _userManager.CreateAsync(user, "P2ssw0rd!"); 39 | } 40 | 41 | if (_context.Contacts.Any()) 42 | { 43 | foreach (var u in _context.Contacts) 44 | { 45 | _context.Remove(u); 46 | } 47 | } 48 | 49 | _context.Contacts.Add(new Contact() { LastName = "Finkley", FirstName = "Adam", Phone = "555-555-5555", Email = "adam@somewhere.com" }); 50 | _context.Contacts.Add(new Contact() { LastName = "Biles", FirstName = "Steven", Phone = "555-555-5555", Email = "sbiles@somewhere.com" }); 51 | _context.SaveChanges(); 52 | } 53 | } 54 | 55 | public interface IDefaultDbContextInitializer 56 | { 57 | bool EnsureCreated(); 58 | Task Seed(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /api/Models/DesignTimeDefaultDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Design; 3 | using Microsoft.Extensions.Configuration; 4 | 5 | namespace aspnetCoreReactTemplate.Models 6 | { 7 | public class BloggingContextFactory : IDesignTimeDbContextFactory 8 | { 9 | public DefaultDbContext CreateDbContext(string[] args) 10 | { 11 | var config = new ConfigurationBuilder() 12 | .SetBasePath(System.IO.Directory.GetCurrentDirectory()) 13 | .AddJsonFile("appsettings.json") 14 | .Build(); 15 | 16 | var options = new DbContextOptionsBuilder(); 17 | options.UseNpgsql(config.GetConnectionString("defaultConnection")); 18 | 19 | return new DefaultDbContext(options.Options); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/NuGet.Config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /api/Program.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using aspnetCoreReactTemplate.Models; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Hosting; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace aspnetCoreReactTemplate 11 | { 12 | public class Program 13 | { 14 | public static void Main(string[] args) 15 | { 16 | var config = new ConfigurationBuilder() 17 | .SetBasePath(Directory.GetCurrentDirectory()) 18 | .AddJsonFile("appsettings.json") 19 | .Build(); 20 | 21 | var host = CreateHostBuilder(config, args).Build(); 22 | using (var scope = host.Services.CreateScope()) 23 | { 24 | var dbContext = scope.ServiceProvider.GetService(); 25 | dbContext.Database.Migrate(); 26 | 27 | var env = scope.ServiceProvider.GetRequiredService(); 28 | if (env.IsDevelopment()) 29 | { 30 | // Seed the database in development mode 31 | var dbInitializer = scope.ServiceProvider.GetRequiredService(); 32 | dbInitializer.Seed().GetAwaiter().GetResult(); 33 | } 34 | } 35 | 36 | host.Run(); 37 | } 38 | 39 | public static IHostBuilder CreateHostBuilder(IConfigurationRoot config, string[] args) => 40 | Host.CreateDefaultBuilder(args) 41 | .ConfigureLogging(logging => 42 | { 43 | logging.ClearProviders(); 44 | // Log to console (stdout) - in production stdout will be written to /var/log/{{app_name}}.out.log 45 | logging.AddConsole(); 46 | logging.AddDebug(); 47 | }) 48 | .ConfigureWebHostDefaults(webBuilder => 49 | { 50 | webBuilder 51 | .UseUrls(config["serverBindingUrl"]) 52 | .UseStartup(); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:5000/", 7 | "sslPort": 0 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "launchUrl": "http://localhost:5000", 15 | "environmentVariables": { 16 | "NODE_PATH": "../node_modules/", 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "api": { 21 | "commandName": "Project", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "NODE_PATH": "../node_modules/", 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | }, 27 | "applicationUrl": "http://localhost:5000/" 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /api/Services/EmailSender.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Mail; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace aspnetCoreReactTemplate.Services 8 | { 9 | public class EmailSender : IEmailSender 10 | { 11 | public EmailSender(IOptions optionsAccessor) 12 | { 13 | Options = optionsAccessor.Value; 14 | } 15 | 16 | public EmailSenderOptions Options { get; } 17 | 18 | public async Task SendEmailAsync(string toEmail, string subject, string htmlMessage, string textMessage = null) 19 | { 20 | MailMessage mailMessage = new MailMessage(); 21 | mailMessage.From = new MailAddress(this.Options.emailFromAddress, this.Options.emailFromName); 22 | mailMessage.To.Add(toEmail); 23 | mailMessage.Body = textMessage; 24 | mailMessage.BodyEncoding = Encoding.UTF8; 25 | mailMessage.Subject = subject; 26 | mailMessage.SubjectEncoding = Encoding.UTF8; 27 | 28 | if (!string.IsNullOrEmpty(htmlMessage)) 29 | { 30 | AlternateView htmlView = AlternateView.CreateAlternateViewFromString(htmlMessage); 31 | htmlView.ContentType = new System.Net.Mime.ContentType("text/html"); 32 | mailMessage.AlternateViews.Add(htmlView); 33 | } 34 | 35 | using (SmtpClient client = new SmtpClient(this.Options.host, this.Options.port)) 36 | { 37 | client.UseDefaultCredentials = false; 38 | client.Credentials = new NetworkCredential(this.Options.username, this.Options.password); 39 | client.EnableSsl = this.Options.enableSSL; 40 | await client.SendMailAsync(mailMessage); 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /api/Services/EmailSenderOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace aspnetCoreReactTemplate.Services 5 | { 6 | public class EmailSenderOptions 7 | { 8 | private string _smtpConfig { get; set; } 9 | public string smtpConfig 10 | { 11 | get { return this._smtpConfig; } 12 | set 13 | { 14 | this._smtpConfig = value; 15 | 16 | // smtpConfig is in username:password@localhost:1025 format; extract the part 17 | var smtpConfigPartsRegEx = new Regex(@"(.*)\:(.*)@(.+)\:(.+)"); 18 | var smtpConfigPartsMatch = smtpConfigPartsRegEx.Match(value); 19 | 20 | this.username = smtpConfigPartsMatch.Groups[1].Value; 21 | this.password = smtpConfigPartsMatch.Groups[2].Value; 22 | this.host = smtpConfigPartsMatch.Groups[3].Value; 23 | this.port = Convert.ToInt32(smtpConfigPartsMatch.Groups[4].Value); 24 | } 25 | } 26 | 27 | public string emailFromName { get; set; } 28 | public string emailFromAddress { get; set; } 29 | public bool enableSSL { get; set; } 30 | public string username { get; protected set; } 31 | public string password { get; protected set; } 32 | public string host { get; protected set; } 33 | public int port { get; protected set; } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /api/Services/IEmailSender.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace aspnetCoreReactTemplate.Services 4 | { 5 | public interface IEmailSender 6 | { 7 | Task SendEmailAsync(string toEmail, string subject, string htmlMessage, string textMessage = null); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /api/Services/JwtOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace aspnetCoreReactTemplate.Services 5 | { 6 | public class JwtOptions 7 | { 8 | public string key { get; set; } 9 | public string issuer { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /api/Startup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.EntityFrameworkCore; 6 | using aspnetCoreReactTemplate.Models; 7 | using aspnetCoreReactTemplate.Services; 8 | using Microsoft.AspNetCore.HttpOverrides; 9 | using Microsoft.AspNetCore.Identity; 10 | using Microsoft.AspNetCore.Authentication.JwtBearer; 11 | using Microsoft.IdentityModel.Tokens; 12 | using Microsoft.Extensions.Hosting; 13 | using System.Text; 14 | 15 | namespace aspnetCoreReactTemplate 16 | { 17 | public class Startup 18 | { 19 | public IHostEnvironment CurrentEnvironment { get; protected set; } 20 | public IConfiguration Configuration { get; } 21 | 22 | public Startup(IConfiguration configuration) 23 | { 24 | Configuration = configuration; 25 | } 26 | 27 | // This method gets called by the runtime. Use this method to add services to the container. 28 | public void ConfigureServices(IServiceCollection services) 29 | { 30 | services.AddControllersWithViews(); 31 | 32 | services.AddEntityFrameworkNpgsql().AddDbContext(options => 33 | { 34 | options.UseNpgsql(Configuration.GetConnectionString("defaultConnection")); 35 | }); 36 | 37 | // Configure Entity Framework Initializer for seeding 38 | services.AddTransient(); 39 | 40 | services.AddDatabaseDeveloperPageExceptionFilter(); 41 | 42 | // Configure Entity Framework Identity for Auth 43 | services.AddIdentity() 44 | .AddEntityFrameworkStores() 45 | .AddDefaultTokenProviders(); 46 | 47 | services.AddAuthentication(options => 48 | { 49 | options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; 50 | options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; 51 | }) 52 | 53 | .AddJwtBearer(config => 54 | { 55 | config.RequireHttpsMetadata = false; 56 | config.SaveToken = true; 57 | 58 | config.TokenValidationParameters = new TokenValidationParameters() 59 | { 60 | ValidIssuer = Configuration["jwt:issuer"], 61 | ValidAudience = Configuration["jwt:issuer"], 62 | IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["jwt:key"])) 63 | }; 64 | }); 65 | 66 | services.AddTransient(); 67 | services.Configure(Configuration.GetSection("email")); 68 | services.Configure(Configuration.GetSection("jwt")); 69 | } 70 | 71 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 72 | public void Configure(IApplicationBuilder app, IHostEnvironment env, ILoggerFactory loggerFactory) 73 | { 74 | // If not requesting /api*, rewrite to / so SPA app will be returned 75 | app.UseSpaFallback(new SpaFallbackOptions() 76 | { 77 | ApiPathPrefix = "/api", 78 | RewritePath = "/" 79 | }); 80 | 81 | app.UseDefaultFiles(); 82 | app.UseHttpsRedirection(); 83 | app.UseStaticFiles(); 84 | 85 | app.UseForwardedHeaders(new ForwardedHeadersOptions 86 | { 87 | // Read and use headers coming from reverse proxy: X-Forwarded-For X-Forwarded-Proto 88 | // This is particularly important so that HttpContet.Request.Scheme will be correct behind a SSL terminating proxy 89 | ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto 90 | }); 91 | 92 | app.UseRouting(); 93 | 94 | app.UseAuthentication(); 95 | app.UseAuthorization(); 96 | 97 | app.UseEndpoints(endpoints => 98 | { 99 | endpoints.MapControllerRoute( 100 | name: "default", 101 | pattern: "{controller=Home}/{action=Index}/{id?}"); 102 | }); 103 | 104 | if (env.IsDevelopment()) 105 | { 106 | app.UseSpa(spa => 107 | { 108 | spa.UseProxyToSpaDevelopmentServer("http://localhost:8080/"); 109 | }); 110 | 111 | app.UseDeveloperExceptionPage(); 112 | app.UseMigrationsEndPoint(); 113 | } 114 | 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /api/ViewModels/ConfirmEmail.cs: -------------------------------------------------------------------------------- 1 | namespace aspnetCoreReactTemplate.ViewModels 2 | { 3 | public class ConfirmEmail 4 | { 5 | public string user_id { get; set; } 6 | public string token { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /api/ViewModels/NewUser.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace aspnetCoreReactTemplate.ViewModels 4 | { 5 | public class NewUser 6 | { 7 | [Required] 8 | [EmailAddress] 9 | public string username { get; set; } 10 | 11 | [Required] 12 | [MinLength(8)] 13 | public string password { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /api/api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net5.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "connectionStrings": { 3 | "defaultConnection": "Host=localhost;Port=5433;Username=postgres;Password=postgres;Database=dotnetcore" 4 | }, 5 | "frontEndUrl": "http://localhost:5000", 6 | "serverBindingUrl": "http://0.0.0.0:5000", 7 | "logging": { 8 | "includeScopes": false, 9 | "logLevel": { 10 | "default": "Debug", 11 | "system": "Information", 12 | "microsoft": "Information" 13 | } 14 | }, 15 | "webClientPath": "../client-react", 16 | "jwt": { 17 | "key" : "2af4ff57-4ca0-4b3a-804b-178ad27aaf88", 18 | "issuer": "aspnet-core-react-template" 19 | }, 20 | "email": { 21 | "smtpConfig": ":@localhost:1025", 22 | "enableSSL": false, 23 | "emailFromName": "aspnet-core-react-template", 24 | "emailFromAddress": "noreply@aspnet-core-react-template.com" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /api/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradymholt/aspnet-core-react-template/2564bc935ee13d92d6c94954646b8db52d6f1035/api/wwwroot/favicon.ico -------------------------------------------------------------------------------- /client-react.test/build/client-react/styles: -------------------------------------------------------------------------------- 1 | ../../../client-react/styles/ -------------------------------------------------------------------------------- /client-react.test/polyfill/localStorage.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | polyfill: function () { 3 | window.localStorage = window.sessionStorage = { 4 | getItem: function (key) { 5 | return this[key]; 6 | }, 7 | setItem: function (key, value) { 8 | this[key] = value; 9 | } 10 | }; 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /client-react.test/tests.tsx: -------------------------------------------------------------------------------- 1 | import { JSDOM } from "jsdom"; 2 | import * as localStorage from "./polyfill/localStorage.js"; 3 | 4 | before(function() { 5 | const dom = new JSDOM("", { url: "http://localhost" }); 6 | 7 | (global as any).window = dom.window; 8 | (global as any).document = dom.window.document; 9 | 10 | localStorage.polyfill(); 11 | 12 | console.log( 13 | "Successfully mocked a DOM with jsdom and polyfilled localStorage." 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /client-react.test/tests/Contacts.tsx: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { mount, shallow, configure } from "enzyme"; 3 | import * as Adapter from "enzyme-adapter-react-16"; 4 | import * as React from "react"; 5 | import { MemoryRouter as Router, Route } from "react-router-dom"; 6 | import { stubFetch } from "../utils"; 7 | import { Contacts } from "../../client-react/components/Contacts"; 8 | 9 | configure({ adapter: new Adapter() }); 10 | 11 | describe(" component ", function() { 12 | it("renders a h1", function() { 13 | let fakeContactsData = [ 14 | { id: 1, lastName: "Smith", firstName: "John" } 15 | ]; 16 | let fetchStub = stubFetch(fakeContactsData); 17 | let emptyArgs: any = {}; 18 | const wrapper = shallow(); 19 | expect(wrapper.find("h1")).to.have.length(1); 20 | fetchStub.restore(); 21 | }); 22 | 23 | it("renders a list of contacts", function(done) { 24 | let fakeContactsData = [ 25 | { id: 1, lastName: "Smith", firstName: "John" } 26 | ]; 27 | let fetchStub = stubFetch(fakeContactsData); 28 | 29 | const wrapper = mount( 30 | 31 | 32 | 33 | ); 34 | 35 | setImmediate(function() { 36 | expect(wrapper.html()).to.contain( 37 | fakeContactsData[fakeContactsData.length - 1].lastName 38 | ); 39 | fetchStub.restore(); 40 | done(); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /client-react.test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "noImplicitAny": true, 5 | "module": "commonjs", 6 | "target":"es2015", 7 | "jsx": "react", 8 | "allowJs": true, 9 | "outDir": "./build" 10 | }, 11 | "files": [ 12 | "./tests.tsx" 13 | ], 14 | "include": [ 15 | "./tests/*.ts*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /client-react.test/utils.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon'; 2 | 3 | 4 | /** 5 | * Stubs browser Fetch API and returns given returnData object 6 | * 7 | * @param returnData 8 | */ 9 | function stubFetch(returnData: Object) { 10 | let g = (global as any); 11 | if (!g.fetch) { 12 | // If fetch is not defined; define it as a dummy function because sinon will only stub a defined function 13 | g.fetch = function () { } 14 | } 15 | 16 | if (!g.Headers) { 17 | g.Headers = function () { 18 | this.set = function(){} 19 | } 20 | } 21 | 22 | let res = { 23 | status: 200, 24 | headers: { 25 | get: function (key: string) { return 'application/json'; } 26 | }, 27 | json: function () { return Promise.resolve(returnData) } 28 | }; 29 | 30 | return sinon.stub(global, "fetch" as any).callsFake(()=> Promise.resolve(res)); 31 | 32 | } 33 | 34 | export { stubFetch } 35 | -------------------------------------------------------------------------------- /client-react/boot.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { BrowserRouter as Router } from 'react-router-dom'; 4 | import Routes from './components/Routes'; 5 | 6 | // Polyfills 7 | import 'whatwg-fetch'; 8 | import './polyfills/object-assign'; 9 | import './polyfills/array-find'; 10 | 11 | // Styles 12 | import '../node_modules/bootstrap/dist/css/bootstrap.css'; 13 | import './styles/global.css'; 14 | 15 | ReactDOM.render( 16 | 17 | 18 | , 19 | document.getElementById("app") 20 | ); 21 | 22 | // Allow Hot Module Reloading 23 | declare var module: any; 24 | if (module.hot) { 25 | module.hot.accept(); 26 | } 27 | -------------------------------------------------------------------------------- /client-react/components/Auth.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link, Redirect, RouteComponentProps } from 'react-router-dom'; 3 | import { RoutePaths } from './Routes'; 4 | import AuthService from '../services/Auth'; 5 | let authStyle = require('../styles/auth.styl'); 6 | let authService = new AuthService(); 7 | 8 | export class SignIn extends React.Component, any> { 9 | refs: { 10 | username: HTMLInputElement; 11 | password: HTMLInputElement; 12 | }; 13 | 14 | state = { 15 | initialLoad: true, 16 | error: null as string 17 | }; 18 | 19 | handleSubmit(event: React.FormEvent) { 20 | event.preventDefault(); 21 | 22 | this.setState({ errors: null, initialLoad: false }); 23 | authService.signIn(this.refs.username.value, this.refs.password.value).then(response => { 24 | if (!response.is_error) { 25 | this.props.history.push(RoutePaths.Contacts); 26 | } else { 27 | this.setState({ error: response.error_content.error_description }); 28 | } 29 | }); 30 | } 31 | 32 | render() { 33 | const search = this.props.location.search; 34 | const params = new URLSearchParams(search); 35 | 36 | let initialLoadContent = null; 37 | if (this.state.initialLoad) { 38 | if (params.get('confirmed')) { 39 | initialLoadContent =
40 | Your email address has been successfully confirmed. 41 |
42 | } 43 | 44 | if (params.get('expired')) { 45 | initialLoadContent =
46 | Sesion Expired You need to sign in again. 47 |
48 | } 49 | 50 | if (this.props.history.location.state && this.props.history.location.state.signedOut) { 51 | initialLoadContent =
52 | Signed Out 53 |
54 | } 55 | } 56 | return
57 |
this.handleSubmit(e)}> 58 |

Please sign in

59 | {initialLoadContent} 60 | {this.state.error && 61 |
62 | {this.state.error} 63 |
64 | } 65 | 66 | 67 | 68 | 69 | 70 |
71 |
72 | Register 73 |
74 |
; 75 | } 76 | } 77 | 78 | export class Register extends React.Component { 79 | refs: { 80 | email: HTMLInputElement; 81 | password: HTMLInputElement; 82 | }; 83 | 84 | state = { 85 | registerComplete: false, 86 | errors: {} as { [key: string]: string } 87 | }; 88 | 89 | handleSubmit(event: React.FormEvent) { 90 | event.preventDefault(); 91 | 92 | this.setState({ errors: {} }); 93 | authService.register(this.refs.email.value, this.refs.password.value).then(response => { 94 | if (!response.is_error) { 95 | this.setState({ registerComplete: true }) 96 | } else { 97 | this.setState({ errors: response.error_content }); 98 | } 99 | }); 100 | } 101 | 102 | _formGroupClass(field: string) { 103 | var className = "form-group "; 104 | if (field) { 105 | className += " has-danger" 106 | } 107 | return className; 108 | } 109 | 110 | render() { 111 | if (this.state.registerComplete) { 112 | return 113 | } else { 114 | return
115 |
this.handleSubmit(e)}> 116 |

Please register for access

117 | {this.state.errors.general && 118 |
119 | {this.state.errors.general} 120 |
121 | } 122 |
123 | 124 | 125 |
{this.state.errors.username}
126 |
127 |
128 | 129 | 130 |
{this.state.errors.password}
131 |
132 | 133 |
134 |
; 135 | }; 136 | } 137 | } 138 | 139 | interface RegisterCompleteProps { 140 | email: string; 141 | } 142 | 143 | export class RegisterComplete extends React.Component { 144 | render() { 145 | return
146 |
147 | Success! Your account has been created. 148 |
149 |

150 | A confirmation email has been sent to {this.props.email}. You will need to follow the provided link to confirm your email address before signing in. 151 |

152 | Sign in 153 |
; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /client-react/components/ContactForm.tsx: -------------------------------------------------------------------------------- 1 | import 'object-assign'; 2 | import * as React from 'react'; 3 | import { Link, Redirect, RouteComponentProps } from 'react-router-dom'; 4 | import ContactService, { IContact } from '../services/Contacts' 5 | import { RoutePaths } from './Routes'; 6 | 7 | let contactService = new ContactService(); 8 | 9 | export class ContactForm extends React.Component, any> { 10 | state = { 11 | contact: null as IContact, 12 | errors: {} as { [key: string]: string } 13 | } 14 | 15 | componentDidMount() { 16 | if (this.props.match.path == RoutePaths.ContactEdit) { 17 | contactService.fetch(this.props.match.params.id).then((response) => { 18 | this.setState({ contact: response.content }); 19 | }); 20 | } else { 21 | let newContact: IContact = { 22 | lastName: '', firstName: '', email: '', phone: '' 23 | }; 24 | this.setState({ contact: newContact }); 25 | } 26 | } 27 | 28 | handleSubmit(event: React.FormEvent) { 29 | event.preventDefault(); 30 | this.saveContact(this.state.contact); 31 | } 32 | 33 | handleInputChange(event: React.ChangeEvent) { 34 | const target = event.target; 35 | const value = target.type === 'checkbox' ? target.checked : target.value; 36 | const name = target.name; 37 | let contactUpdates = { 38 | [name]: value 39 | }; 40 | 41 | this.setState({ 42 | contact: Object.assign(this.state.contact, contactUpdates) 43 | }); 44 | } 45 | 46 | saveContact(contact: IContact) { 47 | this.setState({ errors: {} as { [key: string]: string } }); 48 | contactService.save(contact).then((response) => { 49 | if (!response.is_error) { 50 | this.props.history.push(RoutePaths.Contacts); 51 | } else { 52 | this.setState({ errors: response.error_content }); 53 | } 54 | }); 55 | } 56 | 57 | _formGroupClass(field: string) { 58 | var className = "form-group "; 59 | if (field) { 60 | className += " has-danger" 61 | } 62 | return className; 63 | } 64 | 65 | render() { 66 | if (!this.state.contact) { 67 | return
Loading...
; 68 | } 69 | else { 70 | return
71 | {this.state.contact.id ? "Edit Contact" : "New Contact" } 72 |
this.handleSubmit(e)}> 73 |
74 | 75 | this.handleInputChange(e)} className="form-control form-control-danger" required /> 76 |
{this.state.errors.lastName}
77 |
78 |
79 | 80 | this.handleInputChange(e)} className="form-control form-control-danger" required /> 81 |
{this.state.errors.firstName}
82 |
83 |
84 | 85 | this.handleInputChange(e)} className="form-control form-control-danger" /> 86 |
{this.state.errors.email}
87 |
88 |
89 | 90 | this.handleInputChange(e)} className="form-control form-control-danger" /> 91 |
{this.state.errors.phone}
92 |
93 | 94 | Cancel 95 |
96 |
97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /client-react/components/Contacts.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link, Redirect } from 'react-router-dom'; 3 | import { RoutePaths } from './Routes'; 4 | import { ContactForm } from './ContactForm'; 5 | import ContactService, { IContact } from '../services/Contacts'; 6 | import { RouteComponentProps } from "react-router"; 7 | 8 | let contactService = new ContactService(); 9 | 10 | export class Contacts extends React.Component, any> { 11 | refs: { 12 | query: HTMLInputElement; 13 | }; 14 | 15 | state = { 16 | contacts: [] as Array, 17 | editContact: null as Object, 18 | isAddMode: false as boolean, 19 | searchQuery: '' as string 20 | }; 21 | 22 | componentDidMount() { 23 | this.showAll(); 24 | } 25 | 26 | showAll() { 27 | contactService.fetchAll().then((response) => { 28 | this.setState({ searchQuery: '', contacts: response.content }); 29 | }); 30 | } 31 | 32 | handleSearchQueryChange(event: React.ChangeEvent) { 33 | this.setState({ searchQuery: event.target.value }); 34 | } 35 | 36 | handleSeachSubmit(event: React.FormEvent) { 37 | event.preventDefault(); 38 | 39 | if(!this.state.searchQuery){ 40 | this.showAll(); 41 | return; 42 | } 43 | 44 | contactService.search(this.state.searchQuery).then((response) => { 45 | this.setState({ contacts: response.content }); 46 | }); 47 | } 48 | 49 | delete(contact: IContact) { 50 | contactService.delete(contact.id).then((response) => { 51 | let updatedContacts = this.state.contacts; 52 | updatedContacts.splice(updatedContacts.indexOf(contact), 1); 53 | this.setState({ contacts: updatedContacts }); 54 | }); 55 | } 56 | 57 | render() { 58 | return
59 |

Contacts

60 |
this.handleSeachSubmit(e)}> 61 | this.handleSearchQueryChange(e)} placeholder="Search!" /> 62 |   63 |
64 | {this.state.searchQuery && this.state.contacts && this.state.contacts.length == 0 && 65 |

No results!

66 | } 67 | {this.state.contacts && this.state.contacts.length > 0 && 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | {this.state.contacts.map((contact, index) => 80 | 81 | 82 | 83 | 84 | 85 | 87 | 88 | )} 89 | 90 |
Last NameFirst NameEmailPhone
{contact.lastName}{contact.firstName}{contact.email}{contact.phone}edit 86 |
91 | } 92 | {this.state.searchQuery && 93 | 94 | } 95 | add 96 | 97 |
98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /client-react/components/Error.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link, RouteComponentProps } from 'react-router-dom'; 3 | 4 | export class ErrorPage extends React.Component, any> { 5 | 6 | getErrorCode() { 7 | return this.props.match.params.code; 8 | } 9 | 10 | getErrorMessage() { 11 | let message = null; 12 | switch (this.props.match.params.code) { 13 | case 'email-confirm': 14 | message = 'The email confirmation link you used is invalid or expired.' 15 | break; 16 | default: 17 | message = 'An unknown error has occured.' 18 | } 19 | 20 | return message; 21 | } 22 | 23 | render() { 24 | let code = this.getErrorCode(); 25 | return
26 |

Error

27 |

{this.getErrorMessage()}

28 | {code && 29 |

Code: {code}

30 | } 31 | 32 |
; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client-react/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Link, Redirect } from 'react-router-dom'; 3 | import { RouteComponentProps } from "react-router"; 4 | import { RoutePaths } from './Routes'; 5 | import AuthService from '../services/Auth'; 6 | import AuthStore from '../stores/Auth'; 7 | 8 | let authService = new AuthService(); 9 | 10 | export class Header extends React.Component, any> { 11 | signOut() { 12 | authService.signOut(); 13 | this.props.history.push(RoutePaths.SignIn, { signedOut: true }); 14 | } 15 | 16 | render() { 17 | const search = this.props.location.search; 18 | const params = new URLSearchParams(search); 19 | 20 | return ; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client-react/components/Routes.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Route, Redirect, Switch } from 'react-router-dom'; 4 | import { SignIn, Register } from './Auth'; 5 | import AuthService from '../services/Auth'; 6 | import { ErrorPage } from './Error'; 7 | import { Contacts } from './Contacts'; 8 | import { ContactForm } from './ContactForm'; 9 | import { Header } from './Header'; 10 | 11 | export class RoutePaths { 12 | public static Contacts: string = "/contacts"; 13 | public static ContactEdit: string = "/contacts/edit/:id"; 14 | public static ContactNew: string = "/contacts/new"; 15 | public static SignIn: string = "/"; 16 | public static Register: string = "/register/"; 17 | } 18 | 19 | export default class Routes extends React.Component { 20 | render() { 21 | return 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | } 30 | } 31 | 32 | const DefaultLayout = ({ component: Component, ...rest }: { component: any, path: string, exact?: boolean }) => ( 33 | ( 34 | AuthService.isSignedIn() ? ( 35 |
36 |
37 |
38 | 39 |
40 |
41 | ) : ( 42 | 46 | ) 47 | )} /> 48 | ); 49 | -------------------------------------------------------------------------------- /client-react/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | aspnet-core-react-template 9 | 10 | 11 | 12 |
13 | 14 | <%if (htmlWebpackPlugin.options.useCdn) { %> 15 | 16 | 17 | <% } %> 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /client-react/polyfills/array-find.d.ts: -------------------------------------------------------------------------------- 1 | interface Array { 2 | find(predicate: (search: T) => boolean): T; 3 | } 4 | -------------------------------------------------------------------------------- /client-react/polyfills/array-find.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // https://tc39.github.io/ecma262/#sec-array.prototype.find 4 | if (!Array.prototype.find) { 5 | Object.defineProperty(Array.prototype, 'find', { 6 | value: function (predicate:any) { 7 | // 1. Let O be ? ToObject(this value). 8 | if (this == null) { 9 | throw new TypeError('"this" is null or not defined'); 10 | } 11 | 12 | var o = Object(this); 13 | 14 | // 2. Let len be ? ToLength(? Get(O, "length")). 15 | var len = o.length >>> 0; 16 | 17 | // 3. If IsCallable(predicate) is false, throw a TypeError exception. 18 | if (typeof predicate !== 'function') { 19 | throw new TypeError('predicate must be a function'); 20 | } 21 | 22 | // 4. If thisArg was supplied, let T be thisArg; else let T be undefined. 23 | var thisArg = arguments[1]; 24 | 25 | // 5. Let k be 0. 26 | var k = 0; 27 | 28 | // 6. Repeat, while k < len 29 | while (k < len) { 30 | // a. Let Pk be ! ToString(k). 31 | // b. Let kValue be ? Get(O, Pk). 32 | // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)). 33 | // d. If testResult is true, return kValue. 34 | var kValue = o[k]; 35 | if (predicate.call(thisArg, kValue, k, o)) { 36 | return kValue; 37 | } 38 | // e. Increase k by 1. 39 | k++; 40 | } 41 | 42 | // 7. Return undefined. 43 | return undefined; 44 | } 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /client-react/polyfills/object-assign.d.ts: -------------------------------------------------------------------------------- 1 | declare interface ObjectConstructor { 2 | assign(...objects: Object[]): Object; 3 | } 4 | -------------------------------------------------------------------------------- /client-react/polyfills/object-assign.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | if (typeof Object.assign != 'function') { 4 | Object.assign = function (target: any, varArgs: any) { // .length of function is 2 5 | 'use strict'; 6 | if (target == null) { // TypeError if undefined or null 7 | throw new TypeError('Cannot convert undefined or null to object'); 8 | } 9 | 10 | var to = Object(target); 11 | 12 | for (var index = 1; index < arguments.length; index++) { 13 | var nextSource = arguments[index]; 14 | 15 | if (nextSource != null) { // Skip over if undefined or null 16 | for (var nextKey in nextSource) { 17 | // Avoid bugs when hasOwnProperty is shadowed 18 | if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { 19 | to[nextKey] = nextSource[nextKey]; 20 | } 21 | } 22 | } 23 | } 24 | return to; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /client-react/services/Auth.ts: -------------------------------------------------------------------------------- 1 | import RestUtilities from './RestUtilities'; 2 | import AuthStore from '../stores/Auth'; 3 | 4 | interface IAuthResponse { 5 | token: string; 6 | } 7 | 8 | export default class Auth { 9 | static isSignedIn(): boolean { 10 | return !!AuthStore.getToken(); 11 | } 12 | 13 | signInOrRegister(email: string, password: string, isRegister: boolean = false) { 14 | return RestUtilities.post(`/api/auth/${isRegister ? 'register' : 'login'}`, 15 | `username=${email}&password=${password}${!isRegister ? '&grant_type=password' : ''}`) 16 | .then((response) => { 17 | if (!response.is_error) { 18 | AuthStore.setToken(response.content.token); 19 | } 20 | return response; 21 | }); 22 | } 23 | 24 | signIn(email: string, password: string) { 25 | return this.signInOrRegister(email, password, false); 26 | } 27 | 28 | register(email: string, password: string) { 29 | return this.signInOrRegister(email, password, true); 30 | } 31 | 32 | confirm(token: string): Promise { 33 | return RestUtilities.post('/api/auth/confirm', { token: token }) 34 | .then((response) => { 35 | return true; 36 | }).catch((err) => { 37 | console.log(err); 38 | return false; 39 | }); 40 | } 41 | 42 | signOut(): void { 43 | AuthStore.removeToken(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client-react/services/Contacts.ts: -------------------------------------------------------------------------------- 1 | import RestUtilities from './RestUtilities'; 2 | 3 | export interface IContact { 4 | id?: number, 5 | lastName: string; 6 | firstName: string; 7 | phone: string; 8 | email: string; 9 | } 10 | 11 | export default class Contacts { 12 | fetchAll() { 13 | return RestUtilities.get>('/api/contacts'); 14 | } 15 | 16 | fetch(contactId: number) { 17 | return RestUtilities.get(`/api/contacts/${contactId}`); 18 | } 19 | 20 | search(query: string) { 21 | return RestUtilities.get>(`/api/contacts/search/?q=${query}`); 22 | } 23 | 24 | update(contact: IContact) { 25 | return RestUtilities.put(`/api/contacts/${contact.id}`, contact); 26 | } 27 | 28 | create(contact: IContact) { 29 | return RestUtilities.post('/api/contacts', contact); 30 | } 31 | 32 | save(contact: IContact) { 33 | if (contact.id) { 34 | return this.update(contact); 35 | } else { 36 | return this.create(contact); 37 | } 38 | } 39 | 40 | delete(contactId: number) { 41 | return RestUtilities.delete(`/api/contacts/${contactId}`); 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /client-react/services/RestUtilities.ts: -------------------------------------------------------------------------------- 1 | import AuthStore from "../stores/Auth"; 2 | 3 | export interface IErrorContent { 4 | error: string; 5 | error_description: string; 6 | [key: string]: string; 7 | } 8 | 9 | export interface IRestResponse { 10 | is_error?: boolean; 11 | error_content?: IErrorContent; 12 | content?: T; 13 | } 14 | 15 | export default class RestUtilities { 16 | static get(url: string): Promise> { 17 | return RestUtilities.request("GET", url); 18 | } 19 | 20 | static delete(url: string): Promise> { 21 | return RestUtilities.request("DELETE", url); 22 | } 23 | 24 | static put( 25 | url: string, 26 | data: Object | string 27 | ): Promise> { 28 | return RestUtilities.request("PUT", url, data); 29 | } 30 | 31 | static post( 32 | url: string, 33 | data: Object | string 34 | ): Promise> { 35 | return RestUtilities.request("POST", url, data); 36 | } 37 | 38 | private static request( 39 | method: string, 40 | url: string, 41 | data: Object | string = null 42 | ): Promise> { 43 | let isJsonResponse: boolean = false; 44 | let isBadRequest = false; 45 | let body = data; 46 | let headers = new Headers(); 47 | 48 | headers.set('Authorization',`Bearer ${AuthStore.getToken()}`); 49 | headers.set('Accept','application/json'); 50 | 51 | if (data) { 52 | if (typeof data === "object") { 53 | headers.set('Content-Type','application/json'); 54 | body = JSON.stringify(data); 55 | } else { 56 | headers.set('Content-Type','application/x-www-form-urlencoded'); 57 | } 58 | } 59 | 60 | return fetch(url, { 61 | method: method, 62 | headers: headers, 63 | body: body 64 | }).then((response: any) => { 65 | if (response.status == 401) { 66 | // Unauthorized; redirect to sign-in 67 | AuthStore.removeToken(); 68 | window.location.replace(`/?expired=1`); 69 | } 70 | 71 | isBadRequest = response.status == 400; 72 | 73 | let responseContentType = response.headers.get("content-type"); 74 | if ( 75 | responseContentType && 76 | responseContentType.indexOf("application/json") !== -1 77 | ) { 78 | isJsonResponse = true; 79 | return response.json(); 80 | } else { 81 | return response.text(); 82 | } 83 | }) 84 | .then((responseContent: any) => { 85 | let response: IRestResponse = { 86 | is_error: isBadRequest, 87 | error_content: isBadRequest ? responseContent : null, 88 | content: isBadRequest ? null : responseContent 89 | }; 90 | return response; 91 | }); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /client-react/stores/Auth.ts: -------------------------------------------------------------------------------- 1 | export default class Auth { 2 | static STORAGE_KEY: string = "token"; 3 | 4 | static getToken() { 5 | return window.localStorage.getItem(Auth.STORAGE_KEY); 6 | } 7 | 8 | static setToken(token: string) { 9 | window.localStorage.setItem(Auth.STORAGE_KEY, token); 10 | } 11 | 12 | static removeToken(): void { 13 | window.localStorage.removeItem(Auth.STORAGE_KEY); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client-react/styles/auth.styl: -------------------------------------------------------------------------------- 1 | .auth { 2 | margin: 0 auto; 3 | width:500px; 4 | margin-top:50px; 5 | padding: 25px; 6 | background-color: #f2f2f2; 7 | } 8 | 9 | .authEtc { 10 | margin-top:20px; 11 | } 12 | 13 | .formAuth { 14 | .form-signin-heading, .checkbox { 15 | margin-bottom: 10px; 16 | } 17 | 18 | .checkbox { 19 | font-weight: normal; 20 | } 21 | 22 | .form-control { 23 | position: relative; 24 | height: auto; 25 | -webkit-box-sizing: border-box; 26 | box-sizing: border-box; 27 | padding: 10px; 28 | font-size: 16px; 29 | 30 | &:focus { 31 | z-index: 2; 32 | } 33 | } 34 | 35 | input[type="email"] { 36 | margin-bottom: -1px; 37 | border-bottom-right-radius: 0; 38 | border-bottom-left-radius: 0; 39 | } 40 | 41 | input[type="password"] { 42 | margin-bottom: 10px; 43 | border-top-left-radius: 0; 44 | border-top-right-radius: 0; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client-react/styles/contacts.styl: -------------------------------------------------------------------------------- 1 | .content { 2 | padding: 3rem 1.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /client-react/styles/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 5rem; 3 | font-family: "century gothic", verdana; 4 | 5 | } 6 | 7 | .btn-link { 8 | cursor: pointer; 9 | } 10 | 11 | .btn { 12 | margin: 10px 3px 10px 3px; 13 | } 14 | 15 | .table td{ 16 | vertical-align: middle; 17 | } 18 | -------------------------------------------------------------------------------- /client-react/styles/styles.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.styl" { 2 | let styles: any; 3 | export default styles; 4 | } 5 | 6 | declare module "*.css" { 7 | let styles: any; 8 | export default styles; 9 | } 10 | -------------------------------------------------------------------------------- /client-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "noImplicitAny": true, 5 | "module": "es2015", 6 | "target": "es2015", 7 | "jsx": "react" 8 | }, 9 | "files": ["./boot.tsx", "./styles/styles.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /client-react/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | var releaseConfig = require("./webpack.config.release"); 4 | var isProductionEnvironment = 5 | process.env.ASPNETCORE_ENVIRONMENT === "Production"; 6 | var path = require("path"); 7 | var merge = require("extendify")({ isDeep: true, arrays: "replace" }); 8 | 9 | var config = { 10 | mode: "development", 11 | entry: { 12 | main: path.join(__dirname, "boot.tsx") 13 | }, 14 | output: { 15 | path: path.join(__dirname, "../api/", "wwwroot"), 16 | filename: "[name].js", 17 | publicPath: "/" 18 | }, 19 | resolve: { 20 | extensions: [".ts", ".tsx", ".js", ".styl", ".css"] 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.styl$/, 26 | use: [ 27 | { 28 | loader: "style-loader" 29 | }, 30 | { 31 | loader: "css-loader", 32 | options: { 33 | modules: true, 34 | importLoaders: 2, 35 | sourceMap: false 36 | } 37 | }, 38 | { 39 | loader: "stylus-loader" 40 | } 41 | ] 42 | }, 43 | { test: /\.ts(x?)$/, loaders: ["ts-loader"] }, 44 | { test: /\.css/, loader: "style-loader!css-loader" }, 45 | { 46 | test: /\.(png|woff|woff2|eot|ttf|svg)$/, 47 | loader: "url-loader?limit=100000" 48 | } 49 | ] 50 | }, 51 | devtool: "inline-source-map", 52 | plugins: [ 53 | // plugins should not be empty: https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.SpaServices'[ 54 | new HtmlWebpackPlugin({ 55 | template: path.join(__dirname, "index.ejs"), 56 | inject: true 57 | }) 58 | // new webpack.NamedModulesPlugin() 59 | // We do not use ExtractTextPlugin in development mode so that HMR will work with styles 60 | ] 61 | }; 62 | 63 | if (isProductionEnvironment) { 64 | // Merge production config 65 | config = merge(config, releaseConfig); 66 | } 67 | 68 | module.exports = config; 69 | -------------------------------------------------------------------------------- /client-react/webpack.config.release.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | var ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | var path = require('path'); 5 | 6 | var config = { 7 | mode: "production", 8 | module: { 9 | rules: [ 10 | // Use react-hot for HMR and then ts-loader to transpile TS (pass path to tsconfig because it is not in root (cwd) path) 11 | { test: /\.ts(x?)$/, loaders: ['ts-loader'] }, 12 | { test: /\.styl$/, loader: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader?modules&importLoaders=2&sourceMap&localIdentName=[local]___[hash:base64:5]!stylus-loader' }) }, 13 | { test: /\.css/, loader: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader' }) } 14 | ] 15 | }, 16 | devtool: '', 17 | externals: { 18 | react: 'React', 19 | 'react-dom': 'ReactDOM' 20 | }, 21 | plugins: [ 22 | new HtmlWebpackPlugin({ 23 | release: true, 24 | template: path.join(__dirname, 'index.ejs'), 25 | useCdn: true, 26 | minify: { 27 | collapseWhitespace: true, 28 | removeComments: true 29 | } 30 | }), 31 | new ExtractTextPlugin("styles.css") 32 | ] 33 | }; 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | postgres: 4 | image: postgres:9.5 5 | environment: 6 | - POSTGRES_USER=postgres 7 | - POSTGRES_PASSWORD=postgres 8 | ports: 9 | - "5433:5432" 10 | smtp: 11 | image: jeanberu/mailcatcher 12 | environment: 13 | - SMTP_PORT=1025 14 | - HTTP_PORT=1080 15 | ports: 16 | - "1025:1025" 17 | - "1080:1080" 18 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "5.0.400" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ops/README.md: -------------------------------------------------------------------------------- 1 | This folder contains [Ansible](https://www.ansible.com/) assets responsible for provisioning hosts and deploying this application. 2 | 3 | ## Setup 4 | 5 | 1. Procure access to Ubuntu 16.04 (Xenial) or Ubuntu 18.04 (Bionic) host which will be used to host this application. [AWS](aws.amazon.com) or [Digital Ocean](https://m.do.co/c/974ef9a471c1) are good options. 6 | 2. Setup DNS records to point to these host(s). 7 | 3. Create `config.yml` file in this directory, using `config.yml.example` as a pattern. 8 | 9 | ## Usage 10 | 11 | From the root of this respository, run one of the following commands: 12 | - `npm run provision:prod`: This will provision all production hosts specified in config.yml file. 13 | - `npm run deploy:prod`: This will deploy the app to all production hosts specified in config.yml file. 14 | 15 | ## Notes 16 | - The deploy.yml and provision.yml playbooks were written against and tested on Ubuntu 16. 17 | - The [Ansible Best Practices](http://docs.ansible.com/ansible/playbooks_best_practices.html) document demonstrates using /groups_vars/... for application environment variables (i.e. production / staging) and /group_vars/all for global variables. However, we are using inventory group variables, all contained within the inventory file (config.yml) to define environment and global variables. Because of this, all the variables are in a single location and easily managed. 18 | -------------------------------------------------------------------------------- /ops/config.yml.example: -------------------------------------------------------------------------------- 1 | all: 2 | vars: 3 | # The following vars apply globally; additional config is in group_vars/all. 4 | app_name: aspnetCoreReactTemplate 5 | production: 6 | hosts: 7 | 0.0.0.0 # The IP address or hostname of the production web server 8 | vars: 9 | deploy_user: jdoe # The name of the remote user account for provisioning and deployment 10 | gh_pubkey_user: johndoe1981 # The GitHub username used to pull the public key for deploy_user authorized_user access 11 | use_ssl: true # If true, SSL cert will be obtained from Let's Encrypt and Nginx provisioned for SSL 12 | letsencrypt_use_live_ca: true # If true, will use the Live Let's Encrypt ACME servers; otherwise will use staging server 13 | database_password: super!secret # PostgreSQL database will be configured with this password 14 | postgresql_backup_to_s3: true # If true, PostgresSQL backups will be moved to S3 storage 15 | s3_key: ABCDEFGHIJKLMNOP # S3 Access Key used for uploading PostgreSQL backups 16 | s3_secret: ABCDEFGHIJKLMNOP # S3 Access Secret used for uploading PostgreSQL backups 17 | jwt_key: 6872f99e-cb09 # The key to use for generating JWTs 18 | smtp_config: user:pass@smtp.domain.com:587 # The SMTP configuration for sending outgoing mail 19 | email_from_address: demo@gmail.com # The email address for outgoing email 20 | -------------------------------------------------------------------------------- /ops/deploy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: deploy 3 | hosts: all 4 | gather_facts: false 5 | remote_user: "{{ deploy_user }}" 6 | roles: 7 | - deploy 8 | post_tasks: 9 | - debug: msg="Deployed to https://{{ webserver_name }}" 10 | -------------------------------------------------------------------------------- /ops/group_vars/all: -------------------------------------------------------------------------------- 1 | --- 2 | database_name: "{{ app_name }}" 3 | database_username: "{{ app_name }}" 4 | source_directory: ../api/bin/Release/netcoreapp3.1/publish/ 5 | deploy_directory: "/home/{{ deploy_user }}/apps/{{ app_name }}" 6 | email_enable_ssl: true 7 | email_from_name: "{{ app_name }}" 8 | postgresql_backup_to_s3: false 9 | s3_bucket_name: "s3://app.{{ app_name }}" 10 | s3_db_backup_location: "{{ s3_bucket_name }}/db_backups" 11 | use_ssl: false 12 | letsencrypt_use_live_ca: false 13 | webserver_user: www-data 14 | webserver_name: "{{ inventory_hostname }}" 15 | 16 | -------------------------------------------------------------------------------- /ops/library/ghetto_json: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Source: https://github.com/FauxFaux/ansible-ghetto-json 4 | 5 | import json 6 | import sys 7 | import shlex 8 | 9 | try: 10 | import commentjson 11 | json_load = commentjson.load 12 | except ImportError: 13 | json_load = json.load 14 | 15 | def main(params_list): 16 | params = dict(x.split("=", 2) for x in params_list) 17 | path = params.pop('path') 18 | changed = False 19 | 20 | for key in params.keys(): 21 | if key.startswith('_ansible_'): 22 | params.pop(key) 23 | 24 | with open(path) as f: 25 | obj = json_load(f) 26 | for (key, target) in params.items(): 27 | parts = key.split('.') 28 | ref = obj 29 | for part in parts[:-1]: 30 | if part not in ref: 31 | ref[part] = {} 32 | ref = ref[part] 33 | 34 | last_part = parts[-1] 35 | if target == 'unset': 36 | if last_part in ref: 37 | del ref[last_part] 38 | changed = True 39 | else: 40 | if target.isdigit(): 41 | target = int(target) 42 | if target == 'null': 43 | target = None 44 | if target == 'false': 45 | target = False 46 | if target == 'true': 47 | target = True 48 | if last_part not in ref or ref[last_part] != target: 49 | ref[last_part] = target 50 | changed = True 51 | 52 | if changed: 53 | with open(path, 'w') as f: 54 | json.dump(obj, f, indent=2, separators=(',', ': '), sort_keys=True) 55 | 56 | print(json.dumps({'changed': changed})) 57 | 58 | 59 | if __name__ == '__main__': 60 | if len(sys.argv) == 2: 61 | main(shlex.split(open(sys.argv[1]).read())) 62 | else: 63 | main(sys.argv[1:]) 64 | -------------------------------------------------------------------------------- /ops/provision.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: provision 3 | hosts: all 4 | remote_user: root 5 | # Do not gather facts here because if host does not have python2 installed (i.e. Ubuntu 16) this will fail initially. Gather facts later... 6 | gather_facts: false 7 | pre_tasks: 8 | - name: 'install python2' 9 | raw: sudo apt-get -y install python-simplejson 10 | - name: gather facts 11 | setup: # This gather facts: http://stackoverflow.com/a/31060268/626911 12 | roles: 13 | - nginx 14 | - role: ssl 15 | when: use_ssl 16 | domainsets: 17 | - domains: 18 | - "{{ webserver_name }}" 19 | - dotnetcore 20 | - supervisor 21 | - { role: postgresql, postgresql_server: yes, postgresql_client: yes, postgresql_backup_enabled: yes } 22 | - role: s3cmd 23 | when: postgresql_backup_to_s3 24 | - firewall 25 | - deploy_user 26 | -------------------------------------------------------------------------------- /ops/roles/deploy/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | appsetting_file: appsettings.json 3 | -------------------------------------------------------------------------------- /ops/roles/deploy/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: 3 | - role: supervisor 4 | -------------------------------------------------------------------------------- /ops/roles/deploy/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: Copy app files 2 | synchronize: src={{ source_directory }} dest={{ deploy_directory }} delete=yes rsync_opts=--exclude=.git/ 3 | notify: Reload supervisor app config 4 | 5 | - name: Configure PostgresSQL connection string 6 | lineinfile: dest="{{ deploy_directory }}/{{ appsetting_file }}" 7 | regexp="defaultConnection\":" 8 | line="\"defaultConnection\"{{':'}} \"Host=127.0.0.1;Username={{ database_username }};Password={{ database_password }};Database={{ database_name }}\"" 9 | state="present" 10 | 11 | - name: Configure appsettings 12 | ghetto_json: 13 | path="{{ deploy_directory }}/{{ appsetting_file }}" 14 | frontEndUrl="http://{{ webserver_name }}/" 15 | jwt.key={{ jwt_key }} 16 | jwt.issuer="http://{{ webserver_name }}/" 17 | email.smtpConfig={{ smtp_config }} 18 | email.enableSSL={{ email_enable_ssl }} 19 | email.emailFromName={{ email_from_name }} 20 | email.emailFromAddress={{ email_from_address }} 21 | -------------------------------------------------------------------------------- /ops/roles/deploy_user/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: 3 | - role: postgresql 4 | -------------------------------------------------------------------------------- /ops/roles/deploy_user/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: Add deploy user 2 | user: name={{ deploy_user }} shell=/bin/bash append=true 3 | 4 | - name: Adding postgres user ({{ postgresql_user }}) to deploy user group ({{ deploy_user }}) to allow usage of psql from /home path 5 | user: name={{ postgresql_user }} 6 | groups={{ deploy_user }} 7 | append=yes 8 | 9 | - name: Add deploy user public key to authorized_keys 10 | authorized_key: user={{ deploy_user }} key=https://github.com/{{ gh_pubkey_user }}.keys 11 | 12 | - name: Add deploy user to sudoers 13 | lineinfile: 14 | "dest=/etc/sudoers 15 | regexp='^{{ deploy_user }} ALL' 16 | line='{{ deploy_user }} ALL=(ALL) NOPASSWD: ALL' 17 | state=present" 18 | 19 | - name: Creates deploy directory for app 20 | file: path={{ deploy_directory }} state=directory owner={{deploy_user}} group={{webserver_user}} mode=0775 21 | -------------------------------------------------------------------------------- /ops/roles/dotnetcore/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dotnetcore_package_name: dotnet-sdk-5.0 3 | -------------------------------------------------------------------------------- /ops/roles/dotnetcore/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Download product repository file 3 | get_url: 4 | url: "https://packages.microsoft.com/config/ubuntu/{{ansible_distribution_major_version}}.04/packages-microsoft-prod.deb" 5 | dest: "/tmp/packages-microsoft-prod.deb" 6 | when: ansible_distribution == "Ubuntu" and ansible_distribution_major_version == "18" 7 | 8 | - name: Execute dehydrated shell script 9 | shell: "dpkg -i /tmp/packages-microsoft-prod.deb" 10 | 11 | - name: Install transport-https 12 | apt: pkg=apt-transport-https state=present update_cache=yes 13 | 14 | - name: Install dotnet core SDK 15 | apt: name={{ dotnetcore_package_name }} state=present 16 | -------------------------------------------------------------------------------- /ops/roles/firewall/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Restart ufw 3 | service: name=ufw state=restarted 4 | -------------------------------------------------------------------------------- /ops/roles/firewall/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install Uncomplicated Firewall (ufw) 3 | apt: pkg=ufw state=present cache_valid_time=86400 4 | 5 | - name: Configure ufw defaults 6 | ufw: direction={{ item.direction }} policy={{ item.policy }} 7 | with_items: 8 | - { direction: 'incoming', policy: 'deny' } 9 | - { direction: 'outgoing', policy: 'allow' } 10 | notify: 11 | - Restart ufw 12 | 13 | - name: Allow OpenSSH 14 | ufw: rule=allow name=OpenSSH 15 | notify: 16 | - Restart ufw 17 | 18 | - name: Allow Nginx 19 | ufw: rule=allow name="Nginx Full" 20 | notify: 21 | - Restart ufw 22 | 23 | - name: Enable ufw 24 | ufw: state=enabled 25 | notify: 26 | - Restart ufw 27 | -------------------------------------------------------------------------------- /ops/roles/nginx/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | nginx_conf_dir: /etc/nginx 3 | nginx_conf_file: "{{ nginx_conf_dir }}/sites-available/{{ app_name }}.conf" 4 | nginx_conf_enabled_link_file: "{{ nginx_conf_dir }}/sites-enabled/{{ app_name }}.conf" 5 | -------------------------------------------------------------------------------- /ops/roles/nginx/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Reload nginx 3 | service: name=nginx state=reloaded enabled=true -------------------------------------------------------------------------------- /ops/roles/nginx/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install Nginx 3 | apt: pkg=nginx state=present update_cache=yes 4 | 5 | - name: Disable the default site 6 | file: path="{{ nginx_conf_dir }}/sites-enabled/default" state=absent 7 | notify: Reload nginx 8 | 9 | - name: Setup app config 10 | template: src="etc_nginx_sites-available.conf.j2" dest={{ nginx_conf_file }} group={{ webserver_user }} owner={{ webserver_user }} 11 | 12 | - name: Enable app 13 | file: src={{ nginx_conf_file }} dest="{{ nginx_conf_enabled_link_file }}" state=link 14 | notify: Reload nginx 15 | -------------------------------------------------------------------------------- /ops/roles/nginx/templates/etc_nginx_sites-available.conf.j2: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; #default_listen_marker 3 | server_name {{ webserver_name }}; 4 | 5 | #ssl_config_marker 6 | 7 | gzip_proxied any; 8 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 9 | 10 | root {{ deploy_directory }}/wwwroot; 11 | index index.html; 12 | 13 | # Root path (to be served by Nginx) 14 | location = / { 15 | } 16 | 17 | # Static content (to be served by Nginx) 18 | location ~ \.(jpg|jpeg|gif|png|ico|css|zip|tgz|gz|rar|bz2|pdf|txt|tar|wav|bmp|rtf|js|flv|swf|html|htm|woff2|svg)$ { 19 | expires 1d; 20 | access_log off; 21 | } 22 | 23 | # Dynamic content (to be served by Kestrel) 24 | location / { 25 | proxy_pass http://0.0.0.0:5000; 26 | proxy_http_version 1.1; 27 | proxy_set_header Upgrade $http_upgrade; 28 | proxy_set_header Connection keep-alive; 29 | proxy_set_header Host $host; 30 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 31 | proxy_set_header X-Forwarded-Proto $scheme; 32 | proxy_cache_bypass $http_upgrade; 33 | } 34 | } 35 | 36 | #ssl_forced_config_marker 37 | -------------------------------------------------------------------------------- /ops/roles/postgresql/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 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 | -------------------------------------------------------------------------------- /ops/roles/postgresql/README.md: -------------------------------------------------------------------------------- 1 | # ansible-postgresql 2 | 3 | [PostgreSQL](http://www.postgresql.org/) is a powerful, open source object-relational database system. 4 | 5 | [![Platforms](http://img.shields.io/badge/platforms-ubuntu-lightgrey.svg?style=flat)](#) 6 | 7 | Tunables 8 | -------- 9 | * `postgresql_client` (boolean) - Install PostgreSQL client? 10 | * `postgresql_server` (boolean) - Install PostgreSQL server? 11 | * `postgresql_user` (string) - User to run postgresql as 12 | * `postgresql_runtime_root` (string) - Directory for runtime data 13 | * `postgresql_pidfile_path` (string) - Path for pidfile 14 | * `postgresql_accepts_external_connections` (boolean) - Allow connections from places that aren't localhost? 15 | * `postgresql_backup_enabled` (boolean) - Enable backups? 16 | * `postgresql_backup_path` (string) - Directory to store backups 17 | * `postgresql_backup_frequency` (string) - Frequency of backups 18 | 19 | Dependencies 20 | ------------ 21 | * [telusdigital.apt-repository](https://github.com/telusdigital/ansible-apt-repository/) 22 | 23 | Example Playbook 24 | ---------------- 25 | - hosts: servers 26 | roles: 27 | - role: telusdigital.postgresql 28 | postgresql_server: yes 29 | postgresql_backup_enabled: yes 30 | postgresql_backup_frequency: daily 31 | postgresql_backup_path: /data/backup/postgresql 32 | 33 | License 34 | ------- 35 | [MIT](https://tldrlegal.com/license/mit-license) 36 | 37 | Contributors 38 | ------------ 39 | * [Chris Olstrom](https://colstrom.github.io/) | [e-mail](mailto:chris@olstrom.com) | [Twitter](https://twitter.com/ChrisOlstrom) 40 | * Aaron Pederson 41 | * Steven Harradine 42 | -------------------------------------------------------------------------------- /ops/roles/postgresql/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | postgresql_client: no 3 | postgresql_server: no 4 | 5 | postgresql_user: postgres 6 | 7 | postgresql_runtime_root: "{{ runtime_root | default('/var/run') }}/postgresql" 8 | postgresql_pidfile_path: "{{ postgresql_runtime_root }}/postgresql.pid" 9 | 10 | postgresql_log_root: "{{ log_root | default('/var/log') }}/postgresql" 11 | postgresql_log_path: "{{ postgresql_log_root }}/postgresql-main.log" 12 | 13 | postgresql_accepts_external_connections: yes 14 | 15 | postgresql_backup_enabled: no 16 | postgresql_backup_path: /data/backup/postgresql 17 | postgresql_backup_frequency: daily 18 | postgresql_backup_to_s3: false 19 | -------------------------------------------------------------------------------- /ops/roles/postgresql/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Reload Service | postgres 3 | service: 4 | state: reloaded 5 | name: postgresql 6 | tags: 7 | - disruptive 8 | -------------------------------------------------------------------------------- /ops/roles/postgresql/tasks/backup.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Directory Exists | {{ postgresql_backup_path }}" 3 | file: 4 | state: directory 5 | path: "{{ postgresql_backup_path }}" 6 | owner: "{{ postgresql_user }}" 7 | group: staff 8 | mode: 0775 9 | when: postgresql_backup_enabled 10 | tags: 11 | - directory-structure 12 | - postgres 13 | - backup 14 | 15 | - name: Copy backup script 16 | become: true 17 | become_user: postgres 18 | template: src="pgsql_backup.sh.j2" dest="{{ postgresql_backup_path }}/pgsql_backup.sh" mode="u+x" 19 | 20 | - name: Setup Automated Backups via cron 21 | become: true 22 | become_user: postgres 23 | cron: 24 | name: postgres-backup 25 | special_time: "{{ postgresql_backup_frequency }}" 26 | job: "{{ postgresql_backup_path }}/pgsql_backup.sh" 27 | when: postgresql_backup_enabled 28 | tags: 29 | - postgres 30 | - backup 31 | - using-cron 32 | -------------------------------------------------------------------------------- /ops/roles/postgresql/tasks/configure.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Directory Exists | {{ postgresql_runtime_root }}" 3 | file: 4 | state: directory 5 | path: "{{ postgresql_runtime_root }}" 6 | owner: "{{ postgresql_user }}" 7 | group: staff 8 | mode: 0775 9 | tags: 10 | - directory-structure 11 | - runtime-data 12 | - postgres 13 | 14 | - name: Configure | postgres | pidfile 15 | lineinfile: 16 | state: present 17 | dest: "/etc/postgresql/{{ postgresql_major_version }}/main/postgresql.conf" 18 | regexp: '^#*external_pid_file' 19 | line: "external_pid_file = '{{ postgresql_pidfile_path }}'" 20 | notify: Reload Service | postgres 21 | tags: 22 | - pidfile 23 | - service 24 | 25 | - name: Configure | postgres | listen_address 26 | lineinfile: 27 | state: present 28 | dest: "/etc/postgresql/{{ postgresql_major_version }}/main/postgresql.conf" 29 | regexp: '^#* *listen_addresses =' 30 | line: "listen_addresses = '*'" 31 | notify: Reload Service | postgres 32 | tags: 33 | - networking 34 | when: postgresql_accepts_external_connections 35 | 36 | - name: Configure | postgres | pg_hba.conf 37 | lineinfile: 38 | state: present 39 | dest: "/etc/postgresql/{{ postgresql_major_version }}/main/pg_hba.conf" 40 | regexp: "^#* *host {{ item }}" 41 | line: "host {{ item }} {{ database_username | default(project) }} all md5" 42 | with_items: 43 | - template1 44 | - "{{ database_name | default(project) }}" 45 | notify: Reload Service | postgres 46 | tags: 47 | - networking 48 | when: postgresql_accepts_external_connections 49 | -------------------------------------------------------------------------------- /ops/roles/postgresql/tasks/install.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install Packages | apt 3 | apt: 4 | state: latest 5 | pkg: 'postgresql-client' 6 | when: postgresql_client 7 | tags: 8 | - software-installation 9 | - using-apt 10 | 11 | - name: Install Packages | apt 12 | apt: 13 | state: latest 14 | pkg: [ 15 | 'libpq-dev', 16 | 'python-dev', 17 | 'python-pip', 18 | 'postgresql', 19 | 'postgresql-contrib', 20 | 'postgresql-server-dev-all', 21 | 'python-dev' 22 | ] 23 | when: postgresql_server 24 | tags: 25 | - software-installation 26 | - using-apt 27 | 28 | - name: Install Packages | pip 29 | pip: 30 | state: latest 31 | name: 'psycopg2' 32 | when: postgresql_server 33 | tags: 34 | - software-installation 35 | - using-pip 36 | 37 | - name: Install Packages | apt 38 | apt: 39 | state: latest 40 | pkg: 'p7zip-full' 41 | when: postgresql_backup_enabled 42 | tags: 43 | - p7zip 44 | - backup 45 | - using-apt 46 | 47 | - name: Create Database User 48 | become: true 49 | become_user: postgres 50 | postgresql_user: 51 | state: present 52 | name: "{{ database_username | mandatory }}" 53 | password: "{{ database_password | mandatory }}" 54 | db: template1 55 | priv: CONNECT 56 | # role_attr_flags: SUPERUSER 57 | when: postgresql_server 58 | 59 | - name: Create Database 60 | become: true 61 | become_user: postgres 62 | postgresql_db: 63 | state: present 64 | name: "{{ database_name | mandatory }}" 65 | owner: "{{ database_username | mandatory }}" 66 | when: postgresql_server 67 | 68 | - name: Create Database User 69 | become: true 70 | become_user: postgres 71 | postgresql_user: 72 | state: present 73 | name: "{{ database_username | mandatory }}" 74 | password: "{{ database_password | mandatory }}" 75 | db: "{{ database_name | mandatory }}" 76 | priv: ALL 77 | role_attr_flags: CREATEDB 78 | when: postgresql_server 79 | -------------------------------------------------------------------------------- /ops/roles/postgresql/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include: install.yml 3 | - { include: backup.yml, when: postgresql_server } 4 | - { include: configure.yml, when: postgresql_server } 5 | -------------------------------------------------------------------------------- /ops/roles/postgresql/templates/pgsql_backup.sh.j2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Halt on error 4 | set -e 5 | 6 | BACKUP_FILENAME=backup-`date +\\%Y\\%m\\%d\\%H.7z` 7 | echo -e "Dumping {{ database_name }} to {{ postgresql_backup_path }}/${BACKUP_FILENAME}" 8 | 9 | pg_dump {{ database_name | default(project) }} | 7z a -an -txz -si -so > {{ postgresql_backup_path }}/${BACKUP_FILENAME} 10 | 11 | {% if (postgresql_backup_to_s3 | bool) == true %} 12 | S3_BUCKET="{{ s3_bucket_name }}/{{ s3_db_backup_location }}" 13 | echo -e "Uploading to S3 bucket ${S3_BUCKET}" 14 | s3cmd mb {{ s3_bucket_name }} 15 | s3cmd put {{ postgresql_backup_path }}/${BACKUP_FILENAME} ${S3_BUCKET}/${BACKUP_FILENAME} 16 | 17 | # Note: Because we are using set -e above, we should not get to this point if there was a problem uploading the backup 18 | # to S3. So, the backup file will just remain on this host. 19 | echo -e "Removing backup file" 20 | rm -f "{{ postgresql_backup_path }}/${BACKUP_FILENAME}" 21 | {% endif %} 22 | -------------------------------------------------------------------------------- /ops/roles/postgresql/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | postgresql_major_version: '10' 3 | -------------------------------------------------------------------------------- /ops/roles/s3cmd/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: 3 | - role: postgresql 4 | -------------------------------------------------------------------------------- /ops/roles/s3cmd/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: Install s3cmd 2 | apt: name=s3cmd state=present 3 | 4 | - name: Configure with .s3cfg file 5 | become: yes 6 | become_user: "{{ postgresql_user }}" 7 | template: 8 | src="s3cfg.j2" 9 | dest="~/.s3cfg" 10 | -------------------------------------------------------------------------------- /ops/roles/s3cmd/templates/s3cfg.j2: -------------------------------------------------------------------------------- 1 | [default] 2 | access_key = {{ s3_key }} 3 | secret_key = {{ s3_secret }} 4 | use_https = True -------------------------------------------------------------------------------- /ops/roles/ssl/defaults/main.yml: -------------------------------------------------------------------------------- 1 | dehydrated_install_dir: /etc/dehydrated 2 | dehydrated_script_name: dehydrated.sh 3 | challenge_root_dir: /var/www/dehydrated 4 | challenge_relative_dir: /.well-known/acme-challenge 5 | letsencrypt_use_live_ca: false 6 | -------------------------------------------------------------------------------- /ops/roles/ssl/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: 3 | - role: nginx 4 | -------------------------------------------------------------------------------- /ops/roles/ssl/tasks/main.yml: -------------------------------------------------------------------------------- 1 | # Source: https://gist.github.com/raphaelm/10226edb0e46f7ce844e 2 | # Source: https://github.com/lukas2511/dehydrated 3 | --- 4 | - name: Ensure Challenge directory exists 5 | file: path="{{ challenge_root_dir }}{{ challenge_relative_dir }}" state=directory mode=0755 owner=root group={{ webserver_user }} recurse=yes 6 | 7 | - name: Ensure curl is installed 8 | apt: name=curl state=present 9 | 10 | - name: Ensure SSL base directory exists 11 | file: path={{ dehydrated_install_dir }} state=directory mode=0750 owner=root group={{ webserver_user }} 12 | 13 | - name: Ensure SSL domain list exists 14 | command: "touch {{ dehydrated_install_dir }}/domains.txt" 15 | args: 16 | creates: "{{ dehydrated_install_dir }}/domains.txt" 17 | 18 | - name: Ensure LE config exists 19 | template: src=config.j2 dest="{{ dehydrated_install_dir }}/config" mode=0750 owner=root 20 | 21 | - name: Download dehydrated shell script 22 | get_url: 23 | url: https://raw.githubusercontent.com/lukas2511/dehydrated/master/dehydrated 24 | dest: "{{ dehydrated_install_dir }}/{{ dehydrated_script_name }}" 25 | mode: 0700 26 | 27 | - name: Add line to domains file 28 | lineinfile: 29 | dest: "{{ dehydrated_install_dir }}/domains.txt" 30 | line: "{{ item.domains | join(' ') }}" 31 | with_items: "{{ domainsets }}" 32 | 33 | - name: Configure Nginx to serve the Challenge directory 34 | blockinfile: 35 | dest: "{{ nginx_conf_file }}" 36 | insertafter: "#ssl_config_marker" 37 | marker: "# {mark} ANSIBLE MANAGED BLOCK (CHALLENGE DIR CONFIG)" 38 | block: | 39 | location ^~ {{ challenge_relative_dir }} { 40 | root {{ challenge_root_dir }}; 41 | } 42 | register: challenge_directory_config 43 | 44 | - name: Reload nginx # Force reload when challenge_directory_config.changed because we need it to run before running LE script 45 | service: name=nginx state=reloaded enabled=true 46 | when: challenge_directory_config.changed 47 | 48 | - name: Execute dehydrated shell script 49 | shell: "./{{ dehydrated_script_name }} --register --accept-terms && ./{{ dehydrated_script_name }} -c" 50 | args: 51 | chdir: "{{ dehydrated_install_dir }}" 52 | notify: Reload nginx 53 | 54 | - name: Configure SSL in Nginx 55 | blockinfile: 56 | dest: "{{ nginx_conf_file }}" 57 | insertafter: "#ssl_config_marker" 58 | marker: "# {mark} ANSIBLE MANAGED BLOCK (SSL CONFIG)" 59 | block: | 60 | listen 443 ssl; 61 | ssl_certificate {{ dehydrated_install_dir }}/certs/{{ webserver_name }}/fullchain.pem; 62 | ssl_certificate_key {{ dehydrated_install_dir }}/certs/{{ webserver_name }}/privkey.pem; 63 | notify: Reload nginx 64 | 65 | - name: Remove default listen port (will be replaced with another server block) 66 | lineinfile: 67 | dest: "{{ nginx_conf_file }}" 68 | state: absent 69 | regexp: '#default_listen_marker' 70 | 71 | - name: Configure forced SSL redirect in Nginx 72 | blockinfile: 73 | dest: "{{ nginx_conf_file }}" 74 | insertafter: "#ssl_forced_config_marker" 75 | marker: "# {mark} ANSIBLE MANAGED BLOCK (FORCE SSL CONFIG)" 76 | block: | 77 | server { 78 | listen 80; 79 | server_name {{ webserver_name }}; 80 | rewrite ^ https://$server_name$request_uri? permanent; 81 | } 82 | notify: Reload nginx 83 | 84 | - name: Add LE cronjob 85 | cron: name=lets-encrypt hour=4 minute=23 day=*/3 job="{{ dehydrated_install_dir }}/{{ dehydrated_script_name }} -c && service nginx reload" 86 | -------------------------------------------------------------------------------- /ops/roles/ssl/templates/config.j2: -------------------------------------------------------------------------------- 1 | ######################################################## 2 | # This is the main config file for dehydrated # 3 | # # 4 | # This file is looked for in the following locations: # 5 | # $SCRIPTDIR/config (next to this script) # 6 | # /usr/local/etc/dehydrated/config # 7 | # /etc/dehydrated/config # 8 | # ${PWD}/config (in current working-directory) # 9 | # # 10 | # Default values of this config are in comments # 11 | ######################################################## 12 | 13 | # Resolve names to addresses of IP version only. (curl) 14 | # supported values: 4, 6 15 | # default: 16 | #IP_VERSION= 17 | 18 | # Path to certificate authority (default: https://acme-v02.api.letsencrypt.org/directory) 19 | CA="{{ "https://acme-v02.api.letsencrypt.org/directory" if (letsencrypt_use_live_ca | bool) == true else "https://acme-staging-v02.api.letsencrypt.org/directory" }}" 20 | 21 | # Path to license agreement (default: https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf) 22 | #LICENSE="https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf" 23 | 24 | # Which challenge should be used? Currently http-01 and dns-01 are supported 25 | CHALLENGETYPE="http-01" 26 | 27 | # Path to a directory containing additional config files, allowing to override 28 | # the defaults found in the main configuration file. Additional config files 29 | # in this directory needs to be named with a '.sh' ending. 30 | # default: 31 | #CONFIG_D= 32 | 33 | # Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined) 34 | #BASEDIR=$SCRIPTDIR 35 | 36 | # File containing the list of domains to request certificates for (default: $BASEDIR/domains.txt) 37 | #DOMAINS_TXT="${BASEDIR}/domains.txt" 38 | 39 | # Output directory for generated certificates 40 | #CERTDIR="${BASEDIR}/certs" 41 | 42 | # Directory for account keys and registration information 43 | #ACCOUNTDIR="${BASEDIR}/accounts" 44 | 45 | # Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: /var/www/dehydrated) 46 | WELLKNOWN="/var/www/dehydrated/.well-known/acme-challenge" 47 | 48 | # Default keysize for private keys (default: 4096) 49 | #KEYSIZE="4096" 50 | 51 | # Path to openssl config file (default: - tries to figure out system default) 52 | #OPENSSL_CNF= 53 | 54 | # Program or function called in certain situations 55 | # 56 | # After generating the challenge-response, or after failed challenge (in this case altname is empty) 57 | # Given arguments: clean_challenge|deploy_challenge altname token-filename token-content 58 | # 59 | # After successfully signing certificate 60 | # Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem 61 | # 62 | # BASEDIR and WELLKNOWN variables are exported and can be used in an external program 63 | # default: 64 | #HOOK= 65 | 66 | # Chain clean_challenge|deploy_challenge arguments together into one hook call per certificate (default: no) 67 | #HOOK_CHAIN="no" 68 | 69 | # Minimum days before expiration to automatically renew certificate (default: 30) 70 | #RENEW_DAYS="30" 71 | 72 | # Regenerate private keys instead of just signing new certificates on renewal (default: yes) 73 | #PRIVATE_KEY_RENEW="yes" 74 | 75 | # Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1 76 | #KEY_ALGO=rsa 77 | 78 | # E-mail to use during the registration (default: ) 79 | #CONTACT_EMAIL= 80 | 81 | # Lockfile location, to prevent concurrent access (default: $BASEDIR/lock) 82 | #LOCKFILE="${BASEDIR}/lock" 83 | 84 | # Option to add CSR-flag indicating OCSP stapling to be mandatory (default: no) 85 | #OCSP_MUST_STAPLE="no" 86 | -------------------------------------------------------------------------------- /ops/roles/supervisor/defaults/main.yml: -------------------------------------------------------------------------------- 1 | log_file: "/var/log/{{ app_name }}.out.log" 2 | error_file: "/var/log/{{ app_name }}.err.log" 3 | -------------------------------------------------------------------------------- /ops/roles/supervisor/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Reload supervisor 3 | become: true 4 | service: name=supervisor state=reloaded enabled=true 5 | 6 | - name: Reload supervisor app config 7 | become: true 8 | command: supervisorctl restart {{ app_name }} 9 | -------------------------------------------------------------------------------- /ops/roles/supervisor/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install Supervisor 3 | become: true 4 | apt: pkg=supervisor state=present cache_valid_time=86400 5 | 6 | - name: Setup app config 7 | become: true 8 | template: src=etc_supervisor_conf.d_app_name.conf.j2 dest=/etc/supervisor/conf.d/{{ app_name }}.conf 9 | notify: Reload supervisor 10 | 11 | - name: Create .NET Core cert cache directory and give access to {{ webserver_user }} 12 | become: true 13 | file: path=/var/www/.dotnet/corefx/cryptography/crls state=directory owner={{ webserver_user }} group={{ webserver_user }} mode=0775 14 | -------------------------------------------------------------------------------- /ops/roles/supervisor/templates/etc_supervisor_conf.d_app_name.conf.j2: -------------------------------------------------------------------------------- 1 | [program:{{ app_name }}] 2 | command=/usr/bin/dotnet {{ deploy_directory }}/api.dll 3 | directory={{ deploy_directory }} 4 | autostart=true 5 | autorestart=true 6 | stderr_logfile={{ error_file }} 7 | stdout_logfile={{ log_file }} 8 | environment=HOME=/var/www/,ASPNETCORE_ENVIRONMENT=Production 9 | user={{ webserver_user }} 10 | stopsignal=INT 11 | stopasgroup=true 12 | killasgroup=true 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aspnet.core.react.template", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "postinstall": "dotnet restore ./api && dotnet restore ./api.test", 8 | "build": "dotnet build ./api", 9 | "test:api": "cd ./api.test && dotnet test", 10 | "pretest:client": "npx tsc -p ./client-react.test/", 11 | "test:client": "mocha --require ignore-styles --recursive client-react.test/build/client-react.test", 12 | "test": "npm run test:api && npm run test:client", 13 | "migrate": "cd ./api/ && node ../scripts/create-migration.js && dotnet ef database update", 14 | "prestart": "docker-compose up -d", 15 | "start": "concurrently \"cd ./api && cross-env NODE_PATH=../node_modules/ ASPNETCORE_ENVIRONMENT=Development dotnet watch run\" \"cd ./client-react && webpack-dev-server\"", 16 | "start:release": "npm run prerelease && cd ./api/bin/Release/netcoreapp2.1/publish/ && dotnet api.dll", 17 | "provision:prod": "ansible-playbook -l production -i ./ops/config.yml ./ops/provision.yml", 18 | "prerelease": "cross-env ASPNETCORE_ENVIRONMENT=Production webpack --config ./client-react/webpack.config.js && cd ./api && dotnet publish --configuration Release", 19 | "deploy:prod": "npm run prerelease && ansible-playbook -l production -i ./ops/config.yml ./ops/deploy.yml", 20 | "ssh:prod": "ssh `grep 'deploy_user=' ./ops/hosts | tail -n1 | awk -F'=' '{ print $2}'`@`awk 'f{print;f=0} /[production]/{f=1}' ./ops/hosts | head -n 1`" 21 | }, 22 | "author": "", 23 | "license": "ISC", 24 | "devDependencies": {}, 25 | "dependencies": { 26 | "@types/chai": "^4.2.7", 27 | "@types/enzyme": "^3.10.4", 28 | "@types/enzyme-adapter-react-16": "^1.0.5", 29 | "@types/jsdom": "^12.2.4", 30 | "@types/mocha": "^5.2.7", 31 | "@types/react": "^16.9.16", 32 | "@types/react-addons-test-utils": "^0.14.25", 33 | "@types/react-dom": "^16.9.4", 34 | "@types/react-router-dom": "^5.1.3", 35 | "@types/sinon": "7.5.1", 36 | "aspnet-webpack": "^3.0.0", 37 | "aspnet-webpack-react": "^4.0.0", 38 | "bootstrap": "4.4.1", 39 | "chai": "^4.2.0", 40 | "concurrently": "^5.0.1", 41 | "cross-env": "^6.0.3", 42 | "css-loader": "^3.3.2", 43 | "dom": "^0.0.3", 44 | "enzyme": "^3.10.0", 45 | "enzyme-adapter-react-16": "^1.15.1", 46 | "extendify": "^1.0.0", 47 | "extract-text-webpack-plugin": "^3.0.2", 48 | "file-loader": "^5.0.2", 49 | "global": "^4.4.0", 50 | "html-webpack-plugin": "^3.2.0", 51 | "ignore-styles": "^5.0.1", 52 | "jsdom": "^15.2.1", 53 | "mocha": "^6.2.2", 54 | "prop-types": "^15.7.2", 55 | "react": "^16.12.0", 56 | "react-addons-test-utils": "^15.6.2", 57 | "react-dom": "^16.12.0", 58 | "react-hot-loader": "^4.12.18", 59 | "react-router": "^5.1.2", 60 | "react-router-dom": "^5.1.2", 61 | "sinon": "7.5.0", 62 | "source-map-loader": "^0.2.4", 63 | "style-loader": "^1.0.1", 64 | "stylus": "^0.54.7", 65 | "stylus-loader": "^3.0.2", 66 | "ts-loader": "^6.2.1", 67 | "typescript": "^3.7.3", 68 | "url-loader": "^3.0.0", 69 | "webpack": "^4.41.2", 70 | "webpack-cli": "^3.3.10", 71 | "webpack-dev-middleware": "^3.7.2", 72 | "webpack-dev-server": "^3.9.0", 73 | "webpack-hot-middleware": "^2.25.0", 74 | "whatwg-fetch": "^3.0.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /scripts/create-migration.js: -------------------------------------------------------------------------------- 1 | const timestamp = Math.floor(new Date().getTime()/1000).toString(); 2 | const execSync = require("child_process").execSync; 3 | execSync(`dotnet ef migrations add ${timestamp}`); 4 | --------------------------------------------------------------------------------